#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
fedresurs_unified.py — объединённый парсер Fedresurs.

Шаг 1: собирает список торгов (TradeList) → сохраняет в БД →
для «Публичного предложения» ищет ссылку на объявление о торгах.

Шаг 2: берёт необработанные last_message_url → парсит лоты →
обновляет БД → отправляет в первую группу Telegram (с учётом исключений).

Шаг 3: примерно раз в час проверяет уже распарсенные лоты, у которых есть
lots_app_time. Если до конца приёма заявок осталось 7 дней, скрипт проверяет
актуальность и отправляет лот во вторую группу, если он не исключён.

Выполняется ОДИН процесс, шаги идут ПО ОЧЕРЕДИ.
"""

import undetected_chromedriver as uc
from pyvirtualdisplay import Display
from bs4 import BeautifulSoup
import subprocess
import glob
import logging
import time
import os
import re
import html as html_mod
import requests
import json
import mysql.connector
from datetime import datetime, timedelta
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import (
    WebDriverException,
    InvalidSessionIdException,
    NoSuchWindowException,
    SessionNotCreatedException,
)


# ===================== НАСТРОЙКИ =====================
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s'
)

OUTPUT_DIR = "/var/www/html/fedres"
os.makedirs(OUTPUT_DIR, exist_ok=True)

DB_CONFIG = {
    'host': 'localhost',
    'user': 'admin',
    'password': 'G_0k2V8Y6-6Sege!DAx',
    'database': 'fedresurs'
}

BASE_URL       = "https://old.bankrot.fedresurs.ru"
TRADE_LIST_URL = f"{BASE_URL}/TradeList.aspx"

# ── Telegram ──
TELEGRAM_BOT_TOKEN = "8141522208:AAHZxJdI76anqke2CkxXf4wYMAxZREstmd4"
TELEGRAM_CHAT_ID   = "-1004316870238"
TELEGRAM_SECOND_CHAT_ID = "-1004358393688"

CHROME_VERSION = 151

# Пауза между полными циклами (сек)
CYCLE_DELAY = 1800          # 30 минут

# Как часто запускать проверку второй группы (сек)
SECOND_SCAN_INTERVAL = 3600   # 1 час

# Максимум неудачных проверок статуса для одного лота (потом помечаем обработанным БЕЗ отправки)
SG_MAX_CHECK_FAILS = 2

# За сколько дней до конца приёма заявок отправлять во вторую группу
SECOND_GROUP_DAYS = 7

# Максимальная длина описания лота в Telegram (символы)
DESCRIPTION_MAX_LEN = 400

MAX_PARSE_ATTEMPTS = 5   # после стольких неудач сдаёмся и помечаем лот

# ── Исключения: лоты с такими классификаторами НЕ отправляются в Telegram ──
EXCLUDED_CLASSIFIERS = [
    "Права требования на краткосрочные долговые обязательства (дебиторская задолженность)",
    "Прочее",
    "Титулы собственности на капитал (акции и другие финансовые инструменты, подтверждающие заключение сделки по поводу движения финансовых ресурсов)",
    "Специализированная техника",
    "Машины и оборудование прочие, не включенные в другие группировки",
    "Ценные бумаги",
    "Оружие спортивное, охотничье и военная техника двойного применения",
]

EXCLUDED_DESCRIPTION_KEYWORDS = [
    "Доля",
    "Доли",
]

# Признаки того, что драйвер Chrome умер или сессия больше невалидна
FATAL_DRIVER_KEYWORDS = (
    'invalid session id',
    'session deleted',
    'chrome not reachable',
    'not reachable',
    'disconnected',
    'not connected',
    'devtools',
    'unable to discover open pages',
    'cannot connect to chrome',
    'browser was closed',
    'crashed',
    'no such window',
    'target window already closed',
    'webview not found',
    'connection aborted',
    'connection refused',
    'broken pipe',
    'remotedisconnected',
    'errno 111',
    'errno 104',
    'name resolution',
    'urlopen',
    'urlerror',
    'dns',
    'temporary failure',
    'timed out receiving message from renderer',
    'renderer timeout',
    'target closed',
    'page crashed',
    'chrome crashed',
)
# =====================================================


def is_fatal_driver_error(e):
    """
    Возвращает True, если ошибка похожа на смерть Chrome/ChromeDriver
    или на невалидную Selenium-сессию.
    """
    if isinstance(e, (InvalidSessionIdException, NoSuchWindowException, SessionNotCreatedException)):
        return True

    if isinstance(e, WebDriverException):
        err = str(e).lower()
        return any(k in err for k in FATAL_DRIVER_KEYWORDS)

    err = str(e).lower()
    return any(
        k in err
        for k in (
            'invalid session id',
            'chrome not reachable',
            'session deleted',
            'no such window',
        )
    )


# ──────────────────────────────────────────────
#  ИНИЦИАЛИЗАЦИЯ БД
# ──────────────────────────────────────────────
def init_db():
    conn = mysql.connector.connect(**DB_CONFIG)
    cursor = conn.cursor()

    columns = [
        # из fedresurs_scraper
        ("last_message_date",   "DATETIME NULL COMMENT 'Дата последнего объявления о торгах'"),
        ("last_message_url",    "VARCHAR(500) NULL COMMENT 'Ссылка на последнее объявление о торгах'"),
        ("message_processed",   "BOOLEAN DEFAULT FALSE COMMENT 'Флаг: карточка должника обработана'"),

        # из fedresurs_message_parser
        ("lots_classifier",     "TEXT NULL COMMENT 'Классификатор лотов'"),
        ("lots_description",    "TEXT NULL COMMENT 'Описание лотов'"),
        ("lots_app_time",       "TEXT NULL COMMENT 'Время приёма заявок (с - по)'"),
        ("lots_initial_price",  "TEXT NULL COMMENT 'Начальная цена'"),
        ("lots_auction_step",   "TEXT NULL COMMENT 'Шаг аукциона'"),
        ("lots_deposit",        "TEXT NULL COMMENT 'Задаток'"),
        ("lots_price_reduction","TEXT NULL COMMENT 'Снижение цены'"),
        ("lots_parsed",         "BOOLEAN DEFAULT FALSE COMMENT 'Флаг: лоты распарсены'"),

        # Новый столбец для второй группы
        ("second_group_processed", "BOOLEAN DEFAULT FALSE COMMENT 'Флаг: лот отправлен во вторую группу или не прошёл проверку' AFTER lots_parsed"),

        # Попытки парсинга
        ("parse_attempts", "INT DEFAULT 0 COMMENT 'Количество неудачных попыток парсинга' AFTER second_group_processed"),

        ("sg_check_attempts", "INT DEFAULT 0 COMMENT 'Счётчик неудачных проверок второй группы' AFTER parse_attempts"),
    ]

    for col_name, col_def in columns:
        cursor.execute(f"SHOW COLUMNS FROM trades LIKE '{col_name}'")
        if not cursor.fetchone():
            cursor.execute(f"ALTER TABLE trades ADD COLUMN {col_name} {col_def}")
            logging.info(f"Добавлена колонка: {col_name}")

    # Защита от старых схем БД, где поля были созданы как VARCHAR/маленькие типы
    for col_name in (
        'lots_classifier',
        'lots_description',
        'lots_app_time',
        'lots_initial_price',
        'lots_auction_step',
        'lots_deposit',
        'lots_price_reduction',
    ):
        try:
            cursor.execute(f"ALTER TABLE trades MODIFY COLUMN {col_name} TEXT NULL")
            logging.info(f"Приведён тип столбца {col_name} к TEXT")
        except Exception as e:
            logging.warning(f"Не удалось привести {col_name} к TEXT: {e}")

    conn.commit()
    cursor.close()
    conn.close()
    logging.info("Инициализация БД завершена")


def get_db_connection():
    return mysql.connector.connect(**DB_CONFIG)


# ──────────────────────────────────────────────
#  ШАГ 1. СБОР СПИСКА ТОРГОВ (TradeList)
# ──────────────────────────────────────────────
def is_valid_trade_row(row):
    if not row.find_all('td'):
        return False
    if 'pager' in row.get('class', []):
        return False
    if row.find('td', colspan=True):
        return False
    text = row.get_text(strip=True)
    return bool(text and len(text) > 15)


def parse_date(date_str):
    if not date_str or date_str.strip() in ('', '-', '—'):
        return None

    for fmt in (
        "%d.%m.%Y %H:%M:%S",
        "%d.%m.%Y %H:%M",
        "%d.%m.%Y",
    ):
        try:
            return datetime.strptime(date_str.strip(), fmt)
        except ValueError:
            continue

    return None


def fetch_trades(driver):
    """Загружает TradeList и возвращает список объявлений."""
    try:
        driver.get(TRADE_LIST_URL)

        WebDriverWait(driver, 40).until(
            EC.presence_of_element_located((By.ID, "ctl00_cphBody_gvTradeList"))
        )

        soup  = BeautifulSoup(driver.page_source, 'html.parser')
        table = soup.find('table', id='ctl00_cphBody_gvTradeList')

        if not table:
            table = soup.find('table', id=re.compile(r'gvTradeList$', re.I))

        if not table:
            logging.error("Таблица торгов не найдена")
            return []

        trades = []
        for row in table.find_all('tr'):
            if not is_valid_trade_row(row):
                continue

            cols = row.find_all('td')
            if len(cols) < 8:
                continue

            trade_number = cols[0].get_text(strip=True).strip()
            if not trade_number or len(trade_number) < 2:
                continue

            trade_date_text   = cols[1].get_text(strip=True)
            publish_date_text = cols[2].get_text(strip=True)

            p_tag = cols[3].find('a')
            platform_name = p_tag.get_text(strip=True) if p_tag else cols[3].get_text(strip=True)
            platform_url  = p_tag['href'] if p_tag and p_tag.has_attr('href') else ''

            d_tag = cols[4].find('a')
            debtor_name = d_tag.get_text(strip=True) if d_tag else cols[4].get_text(strip=True)
            debtor_url  = d_tag['href'] if d_tag and d_tag.has_attr('href') else ''
            if debtor_url and debtor_url.startswith('/'):
                debtor_url = BASE_URL + debtor_url

            t_tag = cols[5].find('a')
            trade_type = t_tag.get_text(strip=True) if t_tag else cols[5].get_text(strip=True)
            trade_url  = t_tag['href'] if t_tag and t_tag.has_attr('href') else ''

            bid_form = cols[6].get_text(strip=True)
            status   = cols[7].get_text(strip=True)

            trades.append({
                "trade_number":  trade_number,
                "trade_date":    trade_date_text,
                "publish_date":  publish_date_text,
                "platform_name": platform_name,
                "platform_url":  platform_url,
                "debtor_name":   debtor_name,
                "debtor_url":    debtor_url,
                "trade_type":    trade_type,
                "trade_url":     trade_url,
                "bid_form":      bid_form,
                "status":        status,
            })

        logging.info(f"Собрано {len(trades)} объявлений")
        return trades

    except Exception as e:
        if is_fatal_driver_error(e):
            logging.error(f"Критическая ошибка драйвера в fetch_trades: {e}")
            raise

        logging.error(f"Ошибка парсинга TradeList: {e}")
        return []

def normalize_message_url(msg_url):
    """Приводит ссылку на сообщение к абсолютному виду."""
    if not msg_url:
        return ""

    msg_url = msg_url.strip()

    if msg_url.startswith("//"):
        return "https:" + msg_url

    if msg_url.startswith("/"):
        return "https://fedresurs.ru" + msg_url

    if not msg_url.startswith("http"):
        return "https://fedresurs.ru/" + msg_url

    return msg_url

def parse_debtor_page(driver, debtor_url, retries=2):
    """
    Ищет ПЕРВОЕ «Объявление о проведении торгов» в карточке должника.

    Что улучшено:
    - проверяет, жив ли драйвер;
    - ждёт завершения загрузки страницы;
    - ищет таблицу сообщений по нескольким признакам;
    - если таблица не найдена, ищет ссылку по всей странице;
    - сохраняет HTML в debug, если ничего не найдено;
    - фатальные ошибки драйвера пробрасывает наверх.
    """
    if not is_driver_alive(driver):
        raise RuntimeError("Драйвер умер перед обработкой карточки должника")

    for attempt in range(1, retries + 1):
        try:
            driver.get(debtor_url)

            # Ждём, пока браузер сам посчитает страницу загруженной
            try:
                WebDriverWait(driver, 25).until(
                    lambda d: d.execute_script("return document.readyState") == "complete"
                )
            except Exception:
                pass

            # Ждём появления признаков страницы сообщений
            try:
                WebDriverWait(driver, 15).until(
                    lambda d: (
                        'Объявление о проведении торгов' in d.page_source
                        or 'Сообщение о результатах торгов' in d.page_source
                        or 'ctl00_cphBody_gvMessages' in d.page_source
                        or '<table' in d.page_source
                    )
                )
            except Exception:
                pass

            time.sleep(1)

            soup = BeautifulSoup(driver.page_source, 'html.parser')
            page_text = soup.get_text(" ", strip=True)

            # Если страница слишком короткая, вероятно, она не загрузилась
            # или old.bankrot показал капчу/заглушку.
            if len(page_text) < 200:
                raise ConnectionError(
                    f"Страница слишком короткая ({len(page_text)} симв.) — "
                    f"возможно, не загрузилась или показана заглушка"
                )

            # ── Основной вариант: таблица сообщений ──
            table = soup.find('table', id='ctl00_cphBody_gvMessages')

            if not table:
                table = soup.find('table', id=re.compile(r'gvMessages$', re.I))

            if not table:
                for t in soup.find_all('table'):
                    txt = t.get_text(' ', strip=True)
                    if (
                        'Объявление о проведении торгов' in txt
                        or 'Сообщение о результатах торгов' in txt
                    ):
                        table = t
                        break

            if table:
                for row in table.find_all('tr'):
                    link = None

                    for a in row.find_all('a', href=True):
                        a_text = a.get_text(' ', strip=True)
                        if 'Объявление о проведении торгов' in a_text:
                            link = a
                            break

                    if not link:
                        continue

                    msg_url = normalize_message_url(link['href'])

                    # Если ссылка не нормальная, пропускаем
                    if not msg_url or msg_url.lower().startswith('javascript:'):
                        continue

                    # Ищем дату в строке таблицы
                    date_text = ''
                    for td in row.find_all('td'):
                        td_text = td.get_text(' ', strip=True)
                        m = re.search(
                            r'\d{2}\.\d{2}\.\d{4}(?:\s+\d{2}:\d{2}(?::\d{2})?)?',
                            td_text
                        )
                        if m:
                            date_text = m.group(0)
                            break

                    # Если в таблице дату не нашли, ищем рядом со ссылкой
                    if not date_text:
                        date_text = find_date_near_tag(link)

                    logging.info(f"Найдено объявление о торгах: {date_text}")
                    return parse_date(date_text), msg_url

            # ── Запасной вариант: таблицы нет, ищем ссылку по всей странице ──
            for a in soup.find_all('a', href=True):
                a_text = a.get_text(' ', strip=True)

                if 'Объявление о проведении торгов' not in a_text:
                    continue

                msg_url = normalize_message_url(a['href'])

                if not msg_url or msg_url.lower().startswith('javascript:'):
                    continue

                date_text = find_date_near_tag(a)

                logging.info(f"Найдено объявление о торгах (fallback): {date_text}")
                return parse_date(date_text), msg_url

            # Страница загрузилась, но объявления нет
            logging.warning(
                f"В карточке {debtor_url} не найдено «Объявление о проведении торгов»"
            )
            return None, None

        except Exception as e:
            if is_fatal_driver_error(e):
                logging.error(
                    f"Критическая ошибка драйвера при обработке {debtor_url}: {e}"
                )
                raise

            logging.warning(
                f"Ошибка обработки карточки {debtor_url} "
                f"(попытка {attempt}/{retries}): {e}"
            )

            if attempt < retries:
                wait = 3 * attempt
                logging.info(f"Повтор через {wait} сек…")
                time.sleep(wait)

                if not is_driver_alive(driver):
                    raise RuntimeError("Драйвер умер после ошибки") from e
            else:
                return None, None


def save_to_db(trades, driver):
    """Сохраняет объявления в БД; для ПП ищет last_message_url."""
    if not trades:
        return

    conn   = get_db_connection()
    cursor = conn.cursor()

    new_inserted     = 0
    processed_public = 0

    try:
        for t in trades:
            try:
                trade_date   = parse_date(t["trade_date"])
                publish_date = parse_date(t["publish_date"])

                cursor.execute("""
                    INSERT INTO trades
                        (trade_number, trade_date, publish_date, platform_name, platform_url,
                         debtor_name, debtor_url, trade_type, trade_url, bid_form, status)
                    VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
                    ON DUPLICATE KEY UPDATE status = VALUES(status)
                """, (
                    t["trade_number"], trade_date, publish_date,
                    t["platform_name"], t["platform_url"],
                    t["debtor_name"], t["debtor_url"],
                    t["trade_type"], t["trade_url"],
                    t["bid_form"], t["status"],
                ))

                if cursor.rowcount == 1:
                    new_inserted += 1

                # Для «Публичного предложения» ищем ссылку на объявление
                if "Публичное предложение" in t.get("trade_type", "") and t.get("debtor_url"):
                    cursor.execute("""
                        SELECT message_processed
                        FROM trades
                        WHERE trade_number = %s AND trade_date = %s
                    """, (t["trade_number"], trade_date))
                    result = cursor.fetchone()

                    if not result or not result[0]:
                        if not is_driver_alive(driver):
                            conn.commit()
                            raise RuntimeError("Драйвер умер перед обработкой карточки должника")

                        msg_date, msg_url = parse_debtor_page(driver, t["debtor_url"])

                        if msg_url:
                            cursor.execute("""
                                UPDATE trades
                                SET last_message_date = %s,
                                    last_message_url  = %s,
                                    message_processed = TRUE
                                WHERE trade_number = %s AND trade_date = %s
                            """, (msg_date, msg_url, t["trade_number"], trade_date))

                            processed_public += 1
                            logging.info(f"✓ Обработано ПП: {t['trade_number']}")

            except Exception as e:
                if is_fatal_driver_error(e) or isinstance(e, RuntimeError):
                    conn.commit()
                    raise

                logging.error(f"Ошибка обработки {t.get('trade_number')}: {e}")

        conn.commit()

    finally:
        cursor.close()
        conn.close()

    logging.info(f"Новых лотов: {new_inserted} | Обработано ПП: {processed_public}")


# ──────────────────────────────────────────────
#  ШАГ 2. ПАРСИНГ СООБЩЕНИЙ (лоты)
# ──────────────────────────────────────────────
def get_unprocessed_trades():
    conn   = get_db_connection()
    cursor = conn.cursor()

    cursor.execute("""
        SELECT trade_number, last_message_url, debtor_name, debtor_url, parse_attempts
        FROM trades
        WHERE last_message_url IS NOT NULL
          AND (lots_parsed = FALSE OR lots_parsed IS NULL)
    """)
    rows = cursor.fetchall()

    cursor.close()
    conn.close()
    return rows


def truncate_db_value(value, max_len=5000):
    """Обрезает слишком длинные значения, чтобы они помещались в MySQL TEXT."""
    if value is None:
        return None
    text = str(value).strip()
    if not text:
        return None
    if len(text) > max_len:
        return text[: max_len - 1] + '…'
    return text


def update_trade_in_db(trade_number, data):
    """Записывает извлечённые данные лотов в БД."""
    conn   = get_db_connection()
    cursor = conn.cursor()

    lots     = data.get('lots', [])
    app_time = data.get('app_time', '')

    classifier      = truncate_db_value(format_lots_for_db(lots, 'classifier'))
    description     = truncate_db_value(format_lots_for_db(lots, 'description'))
    app_time_value  = truncate_db_value(app_time if app_time else None)
    initial_price   = truncate_db_value(format_lots_for_db(lots, 'initial_price'))
    auction_step    = truncate_db_value(format_lots_for_db(lots, 'auction_step'))
    deposit         = truncate_db_value(format_lots_for_db(lots, 'deposit'))
    price_reduction = truncate_db_value(format_lots_for_db(lots, 'price_reduction'))

    cursor.execute("""
        UPDATE trades
        SET lots_classifier      = %s,
            lots_description     = %s,
            lots_app_time        = %s,
            lots_initial_price   = %s,
            lots_auction_step    = %s,
            lots_deposit         = %s,
            lots_price_reduction = %s,
            lots_parsed          = TRUE
        WHERE trade_number = %s
    """, (
        classifier,
        description,
        app_time_value,
        initial_price,
        auction_step,
        deposit,
        price_reduction,
        trade_number,
    ))

    conn.commit()
    cursor.close()
    conn.close()


# ── Очистка текста ──
def clean_text(text):
    """Убирает «Показать ещё», символ |, лишние пробелы."""
    if not text:
        return ""
    text = re.sub(r'Показать\s+ещ[ёе]', '', text, flags=re.IGNORECASE)
    text = text.replace('|', ' ')
    text = re.sub(r'\s+', ' ', text).strip()
    return text


def clean_price(text):
    """Дополнительно убирает «продажи» / «продажная» из значения цены."""
    text = clean_text(text)
    text = re.sub(r'^(?:продажи|продажная)\s+', '', text, flags=re.IGNORECASE)
    return text.strip()


def escape_html(text):
    """Экранирует <, >, & для Telegram (parse_mode=HTML)."""
    return html_mod.escape(text) if text else ""


# ── Форматирование для БД ──
def format_lots_for_db(lots, field_name):
    """
    Один лот  → просто текст.
    Несколько → [(1) - текст] [(2) - текст] …
    """
    if not lots:
        return None

    if len(lots) == 1:
        val = lots[0].get(field_name, "")
        return val if val else None

    parts = []
    for i, lot in enumerate(lots, 1):
        val = lot.get(field_name, "").strip()
        if val:
            parts.append(f"[({i}) - {val}]")

    return " ".join(parts) if parts else None


# ── Парсинг страницы сообщения ──
def is_connection_error(e):
    """Определяет, является ли ошибка сетевой / ошибкой драйвера."""
    if is_fatal_driver_error(e):
        return True

    err = str(e).lower()
    keywords = (
        'connection', 'remote', 'refused', 'aborted', 'disconnected',
        'broken pipe', 'max retries', 'newconnectionerror', 'protocolerror',
        'errno 111', 'errno 104', 'remotedisconnected',
    )
    return any(kw in err for kw in keywords)


def parse_message_page(driver, url, retries=3):
    """
    Загружает страницу сообщения ЕФРСБ и извлекает данные лотов.

    Возвращает:
        dict {'app_time': ..., 'lots': [...]}  — страница загрузилась (даже если лотов 0)
        None                                   — ошибка соединения/драйвера (лот НЕ помечается)
    """
    for attempt in range(1, retries + 1):
        try:
            driver.get(url)

            try:
                WebDriverWait(driver, 15).until(
                    lambda d: (
                        'Начальная цена' in d.page_source
                        or 'Прием заявок' in d.page_source
                        or 'Лоты' in d.page_source
                        or len(d.page_source) > 8000
                    )
                )
            except Exception:
                pass

            time.sleep(2)

            soup      = BeautifulSoup(driver.page_source, 'html.parser')
            page_text = soup.get_text(" ", strip=True)

            if len(page_text) < 200:
                raise ConnectionError(
                    f"Страница слишком короткая ({len(page_text)} симв.) — не загрузилась"
                )

            # ── Время приёма заявок ──
            app_time = ""
            m = re.search(
                r'[Пп]ри[её]м\s+заявок\s+с\s+([\d\.]+\s+[\d\:]+)\s+по\s+([\d\.]+\s+[\d\:]+)',
                page_text,
            )
            if m:
                app_time = f"с {m.group(1).strip()} по {m.group(2).strip()}"
            else:
                m = re.search(
                    r'заявок.*?с\s+([\d\.]+\s+[\d\:]+)\s+по\s+([\d\.]+\s+[\d\:]+)',
                    page_text, re.IGNORECASE,
                )
                if m:
                    app_time = f"с {m.group(1).strip()} по {m.group(2).strip()}"

            # ── Лоты ──
            lots = _parse_lots_from_tables(soup)
            if not lots:
                lots = _parse_lots_from_text(page_text)

            return {'app_time': app_time, 'lots': lots}

        except Exception as e:
            if is_fatal_driver_error(e):
                logging.error(f"Критическая ошибка драйвера при парсинге сообщения {url}: {e}")
                raise

            if is_connection_error(e):
                logging.warning(
                    f"Попытка {attempt}/{retries} — ошибка соединения для {url}: {e}"
                )

                if attempt < retries:
                    wait = 5 * attempt
                    logging.info(f"Повтор через {wait} сек…")
                    time.sleep(wait)
                    continue
            else:
                logging.error(f"Ошибка парсинга {url}: {e}")
                return {'app_time': '', 'lots': []}

    logging.error(f"Все {retries} попытки исчерпаны для {url}")
    return None


def _extract_lot_fields(lot_cell_text, price_cell_text):
    """Извлекает поля одного лота из текста двух ячеек таблицы."""
    desc_m = re.search(
        r'Описание\s*(.*?)(?=Классификатор|$)',
        lot_cell_text, re.IGNORECASE | re.DOTALL,
    )
    description = clean_text(desc_m.group(1)) if desc_m else ""

    class_m = re.search(
        r'Классификатор\s*(.*?)$',
        lot_cell_text, re.IGNORECASE | re.DOTALL,
    )
    classifier = clean_text(class_m.group(1)) if class_m else ""

    price_m = re.search(
        r'Начальная\s+(?:продажная\s+)?цена(?:\s+продажи)?\s*'
        r'(.*?)(?=Шаг\s+аукциона|Задаток|Размер\s+задатка|Снижение\s+цены|$)',
        price_cell_text, re.IGNORECASE | re.DOTALL,
    )
    initial_price = clean_price(price_m.group(1)) if price_m else ""

    step_m = re.search(
        r'Шаг\s+аукциона\s*'
        r'(.*?)(?=Задаток|Размер\s+задатка|Снижение\s+цены|$)',
        price_cell_text, re.IGNORECASE | re.DOTALL,
    )
    auction_step = clean_text(step_m.group(1)) if step_m else ""

    dep_m = re.search(
        r'(?:Задаток|Размер\s+задатка)\s*'
        r'(.*?)(?=Снижение\s+цены|$)',
        price_cell_text, re.IGNORECASE | re.DOTALL,
    )
    deposit = clean_text(dep_m.group(1)) if dep_m else ""

    # ИСПРАВЛЕНО: используем нежадный поиск (.*?) и останавливаемся на следующем лоте или конце
    red_m = re.search(
        r'Снижение\s+цены\s*(.*?)(?=Лот\s*(?:№|N)?\s*\d+|$)',
        price_cell_text, re.IGNORECASE | re.DOTALL,
    )
    price_reduction = clean_text(red_m.group(1)) if red_m else ""

    # Дополнительная страховка: обрезаем всё, что длиннее 2000 символов
    max_len = 2000
    fields = {
        'description': description,
        'classifier': classifier,
        'initial_price': initial_price,
        'auction_step': auction_step,
        'deposit': deposit,
        'price_reduction': price_reduction,
    }
    
    for k, v in fields.items():
        if v and len(v) > max_len:
            fields[k] = v[:max_len] + "…"
            
    return fields


def _parse_lots_from_tables(soup):
    """Ищет таблицу с лотами и парсит каждую строку."""
    lots = []

    for table in soup.find_all('table'):
        table_text = table.get_text()
        if 'Номер лота' not in table_text and 'Начальная цена' not in table_text:
            continue

        for row in table.find_all('tr'):
            cols = row.find_all(['td', 'th'])
            if len(cols) < 3:
                continue

            if not cols[0].get_text(strip=True).isdigit():
                continue

            lot_cell   = cols[1].get_text(" ", strip=True)
            price_cell = cols[2].get_text(" ", strip=True)

            lot_info = _extract_lot_fields(lot_cell, price_cell)
            if any(lot_info.values()):
                lots.append(lot_info)

    return lots


def _parse_lots_from_text(page_text):
    """Фоллбэк: разбивает текст по маркерам «Лот №N»."""
    if 'Начальная цена' not in page_text:
        return []

    blocks = re.split(
        r'(?i)(?:Лот\s*(?:№|N)?\s*\d+|№\s*\d+\.\s*Лот)',
        page_text,
    )
    if len(blocks) <= 1:
        blocks = [page_text]

    lots = []
    for block in blocks:
        if 'Начальная цена' not in block and 'Описание' not in block:
            continue

        lot_info = _extract_lot_fields(block, block)
        if any(lot_info.values()):
            lots.append(lot_info)

    return lots


# ──────────────────────────────────────────────
#  TELEGRAM
# ──────────────────────────────────────────────
def is_excluded_classifier(classifier):
    """True, если классификатор попадает под список исключений."""
    if not classifier or not EXCLUDED_CLASSIFIERS:
        return False

    cl = classifier.lower().strip()
    for exc in EXCLUDED_CLASSIFIERS:
        if exc.lower().strip() in cl:
            return True
    return False


def is_excluded_description(description):
    """True, если в описании есть слово из списка исключений."""
    if not description or not EXCLUDED_DESCRIPTION_KEYWORDS:
        return False

    text = description.lower().strip()
    for keyword in EXCLUDED_DESCRIPTION_KEYWORDS:
        if keyword.lower().strip() in text:
            return True
    return False


def should_skip_lot(lot):
    """True, если лот нужно пропустить и для первой, и для второй группы."""
    return is_excluded_classifier(lot.get('classifier', '')) or is_excluded_description(lot.get('description', ''))


def parse_app_time_window(app_time):
    """
    Разбирает строку времени приёма заявок.

    Поддерживает варианты:
    - 'с 03.08.2026 11:00 по 28.09.2026 11:00'
    - 'Приём заявок с 03.08.2026 11:00 по 28.09.2026 11:00'
    - 'заявок с 03.08.2026 11:00 по 28.09.2026 11:00'
    - 'с 25.07.2026 14:00:00 по 30.07.2026 13:59:00'
    """
    if not app_time:
        return None, None

    patterns = (
        r'^\s*с\s+([\d\.]+\s+[\d:]+)\s+по\s+([\d\.]+\s+[\d:]+)',
        r'[Пп]ри[её]м\s+заявок\s+с\s+([\d\.]+\s+[\d:]+)\s+по\s+([\d\.]+\s+[\d:]+)',
        r'заявок.*?с\s+([\d\.]+\s+[\d:]+)\s+по\s+([\d\.]+\s+[\d:]+)',
        r'\bс\s+([\d\.]+\s+[\d:]+)\s+по\s+([\d\.]+\s+[\d:]+)',
    )

    for pattern in patterns:
        m = re.search(pattern, app_time, re.IGNORECASE)
        if not m:
            continue

        start_text = m.group(1).strip()
        end_text = m.group(2).strip()

        start_dt = parse_date(start_text)
        end_dt = parse_date(end_text)

        if end_dt:
            return start_dt, end_dt

    return None, None


def normalize_text(text):
    """Нормализует текст, убирая лишние пробелы."""
    return re.sub(r'\s+', ' ', (text or '')).strip()


def resolve_url(url):
    """Приводит ссылку к абсолютному виду."""
    if not url:
        return ""
    if url.startswith('//'):
        return f"https:{url}"
    if url.startswith('/'):
        return f"https://fedresurs.ru{url}"
    return url


def find_result_trade_link(driver, page_url):
    """Ищет ссылку на сообщение с результатами торгов."""
    try:
        driver.get(page_url)
        WebDriverWait(driver, 20).until(
            lambda d: 'Связанные сообщения' in d.page_source or len(d.page_source) > 6000
        )
    except Exception:
        pass

    soup = BeautifulSoup(driver.page_source, 'html.parser')

    # Ключевые слова, указывающие на итоговое сообщение
    result_keywords = [
        'результат',
        'заверш',
        'состоял',
        'отмен',
        'продан',
        'реализац',
        'итог',
    ]

    for a in soup.find_all('a', href=True):
        text = normalize_text(a.get_text(' ', strip=True)).lower()
        
        # Пропускаем ссылки на текущую страницу
        if resolve_url(a['href']) == resolve_url(page_url):
            continue
            
        if any(kw in text for kw in result_keywords):
            href = a['href']
            # Проверяем, что это ссылка именно на сообщение, а не на карточку должника
            if 'bankruptmessages' in href or 'Message.aspx' in href or 'Messages' in href:
                return resolve_url(href)

    return None

def _parse_results_table(soup):
    """
    Парсит ВСЕ таблицы результатов на странице (Федресурс часто делает 
    отдельную таблицу для каждого лота).
    Возвращает словарь {номер_лота: текст_результата}.
    """
    results = {}
    for table in soup.find_all('table'):
        head = table.get_text(' ', strip=True).lower()
        if 'результат' not in head or 'лот' not in head:
            continue
        
        for row in table.find_all('tr'):
            cells = row.find_all(['td', 'th'])
            if len(cells) < 2:
                continue
            
            first  = cells[0].get_text(' ', strip=True)
            second = cells[1].get_text(' ', strip=True)
            
            # Ищем номер лота в первой ячейке
            m = re.search(r'лот\s*(?:№|n)?\s*(\d+)', first, re.IGNORECASE)
            if not m:
                # Проверяем вторую ячейку (на случай если колонки перепутаны местами)
                m = re.search(r'лот\s*(?:№|n)?\s*(\d+)', second, re.IGNORECASE)
                if m:
                    first, second = second, first
                else:
                    # Фоллбэк: если первая ячейка содержит ТОЛЬКО число (например, просто "1" или "2")
                    m_num = re.match(r'^\s*(\d+)\s*$', first)
                    if m_num:
                        lot_num = int(m_num.group(1))
                        if second.strip():
                            results[lot_num] = second.strip()
                    continue
                    
            lot_num = int(m.group(1))
            if second.strip():
                results[lot_num] = second.strip()
                
    return results

def _lot_status(text):
    """
    Определяет статус лота по тексту результата.
    Возвращает: 'sold' | 'unsold' | 'unknown'.
    ВАЖНО: сначала проверяем «не продан», т.к. 'состоялись' является
    подстрокой 'не состоялись'.
    """
    t = (text or '').lower()
    if not t:
        return 'unknown'
    unsold_kw = (
        'не состоялись', 'признаны не состоявшимися', 'несостоявшимися',
        'недействитель', 'не действитель', 'отменены',
        'не было подано', 'отсутствие заявок', 'заявок не поступало',
    )
    if any(k in t for k in unsold_kw):
        return 'unsold'
    sold_kw = (
        'торги состоялись', 'состоялись', 'победител', 'лучшая цена',
        'договор купли-продажи', 'продан', 'признан победителем',
    )
    if any(k in t for k in sold_kw):
        return 'sold'
    return 'unknown'

# ──────────────────────────────────────────────
#  JSON-API ФЕДРЕСУРСА (проверка без Chrome)
# ──────────────────────────────────────────────
API_URL = "https://fedresurs.ru/backend/bankruptcy-messages/{mid}"
API_HEADERS = {
    'User-Agent': ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
                   '(KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36'),
    'Accept': 'application/json, text/plain, */*',
    'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8',
    'Referer': 'https://fedresurs.ru/bankruptmessages/',
}
MSG_ID_RE = re.compile(
    r'(?:^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$)'
    r'|(?:^[0-9a-f]{32}$)',
    re.I,
)

def _msg_id_from_url(url):
    """Достаёт id сообщения из ссылки /bankruptmessages/<id>."""
    return (url or '').rstrip('/').split('/')[-1]

def api_get_message(mid):
    """Запрос к JSON-API. Возвращает распарсенный JSON сообщения."""
    r = requests.get(API_URL.format(mid=mid), headers=API_HEADERS, timeout=20)
    r.raise_for_status()
    return r.json()

def _iter_dicts(obj):
    """Рекурсивно перебирает все словари внутри JSON (сначала родитель, потом дети)."""
    if isinstance(obj, dict):
        yield obj
        for v in obj.values():
            yield from _iter_dicts(v)
    elif isinstance(obj, list):
        for v in obj:
            yield from _iter_dicts(v)

def _node_text(node):
    """Собирает все строковые значения внутри узла JSON в один текст."""
    out = []
    def rec(o):
        if isinstance(o, dict):
            for v in o.values():
                rec(v)
        elif isinstance(o, list):
            for v in o:
                rec(v)
        elif isinstance(o, str):
            out.append(o)
    rec(node)
    return ' '.join(out)

def find_result_message_id(data):
    """
    Ищет в JSON объявления id связанного «Сообщения о результатах торгов».
    1) Проходит узлы: самый глубокий узел, где есть слово «результат» и id-подобная строка.
    2) Фолбэк: окно ±400 символов вокруг id в сыром JSON.
    """
    own_id = str(data.get('id', '')) if isinstance(data, dict) else ''
    found = None
    for node in _iter_dicts(data):
        vals = [v for v in node.values() if isinstance(v, str)]
        joined = ' '.join(vals).lower()
        if 'результат' not in joined:
            continue
        ids = [v for v in vals if MSG_ID_RE.match(v.strip()) and v.strip() != own_id]
        if ids:
            found = ids[0].strip()   # переписываем более глубоким узлом
    if found:
        return found
    raw = json.dumps(data, ensure_ascii=False) if not isinstance(data, str) else data
    for m in MSG_ID_RE.finditer(raw):
        if m.group(0) == own_id:
            continue
        window = raw[max(0, m.start() - 400): m.end() + 400].lower()
        if 'результат' in window:
            return m.group(0)
    return None

def parse_lot_results_api(data):
    """
    Извлекает из JSON результатов статусы лотов: {номер: 'sold'/'unsold'}.
    Универсально: ищет узлы, где одновременно есть номер лота и статус.
    """
    results = {}
    for node in _iter_dicts(data):
        txt = _node_text(node)
        if not txt or len(txt) > 5000:
            continue
        m = re.search(r'лот\s*(?:№|n)?\s*(\d+)', txt, re.IGNORECASE)
        if not m:
            continue
        st = _lot_status(txt)
        if st == 'unknown':
            continue
        results[int(m.group(1))] = st   # глубокие узлы перезаписывают поверхностные
    if results:
        return results
    st = _lot_status(_node_text(data))
    return {1: st} if st != 'unknown' else {}

def fetch_results_entry_api(url):
    """
    Быстрая проверка через JSON-API (без Chrome).
    Возвращает тот же формат, что и fetch_results_entry:
    {'fail': bool, 'no_link': bool, 'results': {номер: статус}}
    """
    mid = _msg_id_from_url(url)
    try:
        data = api_get_message(mid)
    except Exception as e:
        logging.warning(f"API: объявление не получено ({mid}): {e}")
        return {'fail': True, 'no_link': False, 'results': {}}
    rid = find_result_message_id(data)
    if not rid:
        # Сообщения о результатах ещё нет → торги идут
        logging.info(f"API: ссылки на результаты нет ({mid}) — торги ещё идут")
        return {'fail': False, 'no_link': True, 'results': {}}
    try:
        rdata = api_get_message(rid)
    except Exception as e:
        logging.warning(f"API: результаты не получены ({rid}): {e}")
        return {'fail': True, 'no_link': False, 'results': {}}
    results = parse_lot_results_api(rdata)
    if not results:
        logging.warning(f"API: статусы лотов не распознаны ({rid})")
        return {'fail': True, 'no_link': False, 'results': {}}
    logging.info(f"API: таблица результатов {results} ({rid})")
    return {'fail': False, 'no_link': False, 'results': results}

ANN_MARKERS = ('Связанные сообщения', 'Лоты', 'Прием заявок', 'Приём заявок')
RES_MARKERS = ('Сообщение о результатах торгов', 'Результат', 'Лоты')

def _wait_page(driver, url, markers, timeout=15, attempts=2):
    """Загружает страницу и ЖДЁТ именно маркеры контента (без OR по длине оболочки)."""
    for attempt in range(1, attempts + 1):
        try:
            driver.get(url)
        except Exception as e:
            logging.warning(f"driver.get({url}) ошибка: {e}")
            return None, ''
        try:
            WebDriverWait(driver, timeout).until(
                lambda d: any(m in d.page_source for m in markers)
            )
        except Exception:
            pass
        soup = BeautifulSoup(driver.page_source, 'html.parser')
        text = soup.get_text(' ', strip=True)
        if len(text) > 500 and any(m in text for m in markers):
            return soup, text
        logging.warning(f"Страница {url} не отрисовалась (попытка {attempt}/{attempts}, длина текста={len(text)})")
        time.sleep(2)
    return None, ''

def find_result_link_via_old_card(driver, debtor_url):
    """
    ЗАПАСНОЙ КАНАЛ: старая карточка должника (old.bankrot.fedresurs.ru) отдаётся
    сервером БЕЗ JavaScript — там в таблице gvMessages есть строка
    «Сообщение о результатах торгов» со ссылкой. Chrome-отрисовка не нужна.
    """
    if not debtor_url:
        return None
    soup, text = _wait_page(driver, debtor_url, ('Тип сообщения',), timeout=15, attempts=1)
    if soup is None:
        return None
    table = soup.find('table', id=re.compile(r'gvMessages$', re.I))
    if not table:
        return None
    for row in table.find_all('tr'):
        for a in row.find_all('a', href=True):
            if 'результатах торгов' in a.get_text(' ', strip=True).lower():
                return normalize_message_url(a['href'])
    return None

def _lot_num_for_trade(trade_number, idx, total):
    """Если строка торгов = один лот с номером в конце (ПП-56571/62), берём его."""
    if total == 1:
        m = re.search(r'[/\-](\d+)$', (trade_number or '').strip())
        if m and 1 <= int(m.group(1)) <= 500:
            return int(m.group(1))
    return idx

def fetch_results_entry(driver, url, debtor_url, cache):
    """
    Одна проверка URL за цикл (кэш). Возвращает:
    {'fail': bool, 'no_link': bool, 'results': {номер_лота: 'sold'/'unsold'/'unknown'}}
    """
    if url in cache:
        return cache[url]
    entry = {'fail': False, 'no_link': False, 'results': {}}

    soup, ann_text = _wait_page(driver, url, ANN_MARKERS)
    result_link = None
    if soup is not None:
        for a in soup.find_all('a', href=True):
            t = normalize_text(a.get_text(' ', strip=True)).lower()
            if resolve_url(a['href']) == resolve_url(url):
                continue
            if any(k in t for k in ('результат', 'заверш', 'состоял', 'отмен', 'продан', 'реализац', 'итог')):
                h = a['href']
                if 'bankruptmessages' in h or 'Message.aspx' in h or 'Messages' in h:
                    result_link = resolve_url(h)
                    break
    if not result_link:
        # SPA не отрисовалась — пробуем старый сайт без JS
        result_link = find_result_link_via_old_card(driver, debtor_url)
        if result_link:
            logging.info(f"Ссылка на результаты получена через старую карточку: {result_link}")

    if not result_link:
        entry['fail'] = (soup is None)   # страница ок, но ссылки нет → торги идут
        entry['no_link'] = (soup is not None)
        cache[url] = entry
        return entry

    rsoup, rtext = _wait_page(driver, result_link, RES_MARKERS)
    if rsoup is None:
        entry['fail'] = True
        cache[url] = entry
        return entry

    table = _parse_results_table(rsoup)
    if table:
        entry['results'] = {num: _lot_status(txt) for num, txt in table.items()}
        logging.info(f"Таблица результатов: {entry['results']} ({result_link})")
    else:
        st = _lot_status(rtext)
        if st == 'unknown':
            entry['fail'] = True
        else:
            entry['results'] = {1: st}
    cache[url] = entry
    return entry

def _set_sg_attempts(trade_number, attempts):
    conn   = get_db_connection()
    cursor = conn.cursor()
    cursor.execute("UPDATE trades SET sg_check_attempts = %s WHERE trade_number = %s",
                   (attempts, trade_number))
    conn.commit(); cursor.close(); conn.close()


def build_telegram_message(data, message_url, debtor_name, debtor_url):
    """
    Собирает HTML-сообщение для Telegram.
    Лоты из EXCLUDED_CLASSIFIERS и EXCLUDED_DESCRIPTION_KEYWORDS пропускаются.
    Если после фильтрации лотов не осталось — возвращает None.
    """
    lots     = data.get('lots', [])
    app_time = data.get('app_time', '')

    if not lots:
        return None

    filtered = [lot for lot in lots if not should_skip_lot(lot)]
    if not filtered:
        return None

    multi = len(filtered) > 1
    lines = []

    for i, lot in enumerate(filtered, 1):
        classifier    = escape_html(lot.get('classifier', ''))
        description   = escape_html(lot.get('description', ''))
        initial_price = escape_html(lot.get('initial_price', ''))
        auction_step  = escape_html(lot.get('auction_step', ''))
        deposit       = escape_html(lot.get('deposit', ''))

        if len(description) > DESCRIPTION_MAX_LEN:
            description = description[:DESCRIPTION_MAX_LEN] + "…"

        if multi:
            lines.append(f"📦 <b>Лот {i}</b>")

        if classifier:
            lines.append(f"🏷 <b>Классификатор:</b> {classifier}")

        if description:
            lines.append(f"📝 <b>Описание:</b> {description}")

        if initial_price:
            lines.append(f"💰 <b>Цена:</b> {initial_price}")

        if app_time and not multi:
            lines.append(f"📅 <b>Заявки:</b> {escape_html(app_time)}")

        parts = []
        if auction_step:
            parts.append(f"Шаг: {auction_step}")
        if deposit:
            parts.append(f"Задаток: {deposit}")
        if parts:
            lines.append(f"📈 <b>{' — '.join(parts)}</b>")

        if multi and i < len(filtered):
            lines.append("")

    lines.append("")

    if app_time and multi:
        lines.append(f"📅 <b>Заявки:</b> {escape_html(app_time)}")

    if message_url:
        lines.append(f'🔗 <a href="{message_url}">Объявление о торгах</a>')

    if debtor_url:
        lines.append(f'📋 <a href="{debtor_url}">Карточка должника</a>')

    if debtor_name:
        lines.append(f"👤 <b>Должник:</b> {escape_html(debtor_name)}")

    return "\n".join(lines)


def send_telegram(message, chat_id=None):
    """Отправляет одно сообщение в Telegram в указанный чат с обработкой 429 ошибки."""
    target_chat_id = chat_id or TELEGRAM_CHAT_ID
    if not target_chat_id:
        logging.warning("Telegram: chat_id не задан, сообщение не отправлено")
        return False
        
    url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
    payload = {
        "chat_id":                  target_chat_id,
        "text":                     message,
        "parse_mode":               "HTML",
        "disable_web_page_preview": True,
    }
    
    # Максимум 1 повторная попытка после получения 429
    for attempt in range(2):  
        try:
            resp = requests.post(url, json=payload, timeout=15)
            if resp.status_code == 200:
                logging.info(f"✓ Telegram: сообщение отправлено в чат {target_chat_id}")
                return True
            elif resp.status_code == 429:
                try:
                    retry_after = resp.json().get('parameters', {}).get('retry_after', 5)
                except Exception:
                    retry_after = 5
                logging.warning(f"⚠️ Telegram API 429: Too Many Requests. Ожидание {retry_after + 1} сек...")
                time.sleep(retry_after + 1)
                continue  # Повторить попытку после ожидания
            else:
                logging.error(f"Telegram API {resp.status_code}: {resp.text[:300]}")
                return False
        except Exception as e:
            logging.error(f"Ошибка отправки в Telegram: {e}")
            return False
            
    logging.error("Не удалось отправить сообщение после повторных попыток (429).")
    return False

def send_telegram_long(message, chat_id=None):
    """Разбивает сообщение длиннее 4096 символов на части."""
    MAX_LEN = 4096
    if len(message) <= MAX_LEN:
        return send_telegram(message, chat_id=chat_id)
        
    parts, current = [], ""
    # Исправлено: используем корректный символ переноса строки \n
    for line in message.split("\n"):
        if len(current) + len(line) + 1 > MAX_LEN:
            if current:
                parts.append(current)
            current = line
        else:
            current = f"{current}\n{line}" if current else line
    if current:
        parts.append(current)
        
    for part in parts:
        send_telegram(part, chat_id=chat_id)
        time.sleep(1.5)  # Задержка между частями одного длинного сообщения


# ──────────────────────────────────────────────
#  ШАГ 2 (обёртка): обработка всех сообщений
# ──────────────────────────────────────────────
def process_messages(driver):
    trades = get_unprocessed_trades()
    if not trades:
        logging.info("Нет необработанных сообщений")
        return

    for trade in trades:
        if not is_driver_alive(driver):
            logging.warning("Драйвер умер — прерываем пакет сообщений")
            break

        trade_number = trade[0]
        url          = trade[1]
        debtor_name  = trade[2] if len(trade) > 2 else ""
        debtor_url   = trade[3] if len(trade) > 3 else ""
        attempts     = trade[4] if len(trade) > 4 else 0

        logging.info(f"Обработка сообщения {trade_number}: {url}")

        try:
            data = parse_message_page(driver, url)

            if data is not None:
                update_trade_in_db(trade_number, data)
                logging.info(f"✓ БД обновлена: {trade_number}")

                tg_msg = build_telegram_message(data, url, debtor_name, debtor_url)
                if tg_msg:
                    if TELEGRAM_CHAT_ID:
                        logging.info(f"Telegram: отправка в первую группу для {trade_number}")
                        send_telegram_long(tg_msg, chat_id=TELEGRAM_CHAT_ID)
                        time.sleep(2)
                    else:
                        logging.warning(f"Telegram: не задан TELEGRAM_CHAT_ID для {trade_number}")
                else:
                    logging.info(
                        f"Telegram пропущен (исключён или нет лотов): {trade_number}"
                    )

            else:
                new_attempts = attempts + 1

                if new_attempts >= MAX_PARSE_ATTEMPTS:
                    update_trade_in_db(trade_number, {'lots': []})
                    logging.error(
                        f"✗ {trade_number}: {MAX_PARSE_ATTEMPTS} попыток исчерпано — "
                        f"помечен как обработанный с пустыми данными"
                    )
                else:
                    _increment_parse_attempts(trade_number, new_attempts)
                    logging.warning(
                        f"⚠ {trade_number}: ошибка соединения "
                        f"(попытка {new_attempts}/{MAX_PARSE_ATTEMPTS}) — "
                        f"повтор в следующем цикле"
                    )

                if not is_driver_alive(driver):
                    logging.warning("Драйвер умер после ошибки — прерываем пакет")
                    break

        except Exception as e:
            if is_fatal_driver_error(e):
                logging.error(f"Критическая ошибка драйвера при обработке сообщения {trade_number}: {e}")
                raise

            logging.error(f"Критическая ошибка при обработке {trade_number}: {e}")

            if not is_driver_alive(driver):
                logging.warning("Драйвер умер — прерываем пакет")
                break


def _increment_parse_attempts(trade_number, attempts):
    """Обновляет счётчик неудачных попыток."""
    conn   = get_db_connection()
    cursor = conn.cursor()

    cursor.execute(
        "UPDATE trades SET parse_attempts = %s WHERE trade_number = %s",
        (attempts, trade_number),
    )

    conn.commit()
    cursor.close()
    conn.close()


# ──────────────────────────────────────────────
#  ШАГ 3. ПОЧАСОВАЯ ПРОВЕРКА ВТОРОЙ ГРУППЫ
# ──────────────────────────────────────────────
def get_second_group_candidates():
    conn   = get_db_connection()
    cursor = conn.cursor()
    cursor.execute("""
        SELECT trade_number, last_message_url, debtor_name, debtor_url,
               lots_app_time, lots_classifier, lots_description,
               lots_initial_price, lots_auction_step, lots_deposit,
               lots_price_reduction, sg_check_attempts
        FROM trades
        WHERE lots_parsed = TRUE
          AND last_message_url IS NOT NULL
          AND lots_app_time IS NOT NULL AND lots_app_time <> ''
          AND (second_group_processed IS NULL OR second_group_processed = FALSE)
        ORDER BY trade_number
    """)
    rows = cursor.fetchall()
    cursor.close(); conn.close()
    return rows


def parse_db_multi_value(value):
    if value is None:
        return {}

    text = str(value).strip()
    if not text:
        return {}

    if re.search(r'\[\(\d+\)\s*-\s*', text):
        parts = re.split(r'\[\((\d+)\)\s*-\s*', text)
        result = {}

        for i in range(1, len(parts), 2):
            if i + 1 >= len(parts):
                break

            try:
                idx = int(parts[i])
            except (ValueError, TypeError):
                continue

            val = parts[i + 1].strip()

            if val.endswith(']'):
                val = val[:-1].strip()

            val = re.sub(r'\s+', ' ', val).strip()

            if val:
                result[idx] = val

        return result

    return {1: re.sub(r'\s+', ' ', text).strip()}


def reconstruct_lots_from_db_fields(
    classifier,
    description,
    initial_price,
    auction_step,
    deposit,
    price_reduction
):
    fields = {
        'classifier':      parse_db_multi_value(classifier),
        'description':     parse_db_multi_value(description),
        'initial_price':   parse_db_multi_value(initial_price),
        'auction_step':    parse_db_multi_value(auction_step),
        'deposit':         parse_db_multi_value(deposit),
        'price_reduction': parse_db_multi_value(price_reduction),
    }

    indices = set()
    for values in fields.values():
        indices.update(values.keys())

    if not indices:
        return []

    lots = []
    for idx in sorted(indices):
        lot = {
            'classifier':      fields['classifier'].get(idx, ''),
            'description':     fields['description'].get(idx, ''),
            'initial_price':   fields['initial_price'].get(idx, ''),
            'auction_step':    fields['auction_step'].get(idx, ''),
            'deposit':         fields['deposit'].get(idx, ''),
            'price_reduction': fields['price_reduction'].get(idx, ''),
        }

        if any((lot.get(k) or '').strip() for k in lot):
            lots.append(lot)

    return lots


def mark_second_group_processed(trade_number):
    conn   = get_db_connection()
    cursor = conn.cursor()

    try:
        cursor.execute("""
            UPDATE trades
            SET second_group_processed = TRUE
            WHERE trade_number = %s
        """, (trade_number,))
        conn.commit()
    finally:
        cursor.close()
        conn.close()


def process_second_group(driver):
    if not TELEGRAM_SECOND_CHAT_ID:
        logging.warning("TELEGRAM_SECOND_CHAT_ID не задан — проверка второй группы пропущена")
        return
    trades = get_second_group_candidates()
    if not trades:
        logging.info("Нет лотов для проверки во вторую группу")
        return
    logging.info(f"Проверка второй группы: найдено {len(trades)} кандидатов")
    cache   = {}     # один запрос на уникальный URL за цикл
    handled = set()

    for trade in trades:
        trade_number = trade[0]
        if trade_number in handled:
            continue
        url          = trade[1] or ''
        debtor_name  = trade[2] or ''
        debtor_url   = trade[3] or ''
        app_time     = trade[4] or ''
        classifier, description, initial_price = trade[5], trade[6], trade[7]
        auction_step, deposit, price_reduction = trade[8], trade[9], trade[10]
        attempts     = trade[11] or 0

        if not url:
            mark_second_group_processed(trade_number); handled.add(trade_number); continue
        start_dt, end_dt = parse_app_time_window(app_time)
        if not end_dt:
            mark_second_group_processed(trade_number); handled.add(trade_number); continue
        now = datetime.now()
        if now < end_dt - timedelta(days=SECOND_GROUP_DAYS):
            continue
        if now > end_dt:
            logging.info(f"Лот {trade_number}: приём заявок завершился — помечаем обработанным")
            mark_second_group_processed(trade_number); handled.add(trade_number); continue

        lots = reconstruct_lots_from_db_fields(classifier, description, initial_price,
                                               auction_step, deposit, price_reduction)
        data = {'app_time': app_time, 'lots': lots}

        if not is_driver_alive(driver):
            logging.warning("Драйвер умер — прерываем проверку второй группы")
            break

        # ── Быстрый путь: JSON-API без Chrome ──
        entry = fetch_results_entry_api(url)
        # ── Запасной путь: Chrome, если API недоступен ──
        if entry['fail']:
            try:
                entry = fetch_results_entry(driver, url, debtor_url, cache)
            except Exception as e:
                if is_fatal_driver_error(e):
                    logging.error(f"Критическая ошибка драйвера при проверке второй группы: {e}")
                    raise
                logging.warning(f"Не удалось проверить результаты торгов для {trade_number}: {e}")
                if not is_driver_alive(driver):
                    break
                if is_connection_error(e):
                    break
                entry = {'fail': True, 'no_link': False, 'results': {}}

        # ── Сбой проверки: счётчик попыток, чтобы очередь не росла вечно ──
        if entry['fail']:
            new_attempts = attempts + 1
            if new_attempts >= SG_MAX_CHECK_FAILS:
                logging.warning(
                    f"Лот {trade_number}: {new_attempts} проверок без результата — "
                    f"помечаем обработанным БЕЗ отправки (лимит, чтобы очередь не росла)"
                )
                mark_second_group_processed(trade_number); handled.add(trade_number); continue
            _set_sg_attempts(trade_number, new_attempts)
            logging.warning(
                f"Лот {trade_number}: статус не проверен (попытка {new_attempts}/{SG_MAX_CHECK_FAILS}) — повтор"
            )
            continue

        # ── Определяем непроданные лоты ──
        if entry['no_link']:
            unsold_lots = list(lots)                      # результатов ещё нет → торги идут
        else:
            unsold_lots, unknown = [], False
            for i, lot in enumerate(lots, 1):
                num = _lot_num_for_trade(trade_number, i, len(lots))
                st  = entry['results'].get(num, 'unknown')
                logging.info(f"  {trade_number}: лот №{num} → '{st}'")
                if st == 'sold':
                    continue
                if st == 'unknown':
                    unknown = True
                    break
                unsold_lots.append(lot)
            if unknown:
                new_attempts = attempts + 1
                if new_attempts >= SG_MAX_CHECK_FAILS:
                    logging.warning(f"Лот {trade_number}: лимит попыток — помечаем обработанным БЕЗ отправки")
                    mark_second_group_processed(trade_number); handled.add(trade_number); continue
                _set_sg_attempts(trade_number, new_attempts)
                continue

        if not unsold_lots:
            logging.info(f"Лот {trade_number}: все лоты проданы — во вторую группу не отправляем")
            mark_second_group_processed(trade_number); handled.add(trade_number); continue

        if len(unsold_lots) < len(lots):
            logging.info(f"Лот {trade_number}: продана часть лотов — отправляем {len(unsold_lots)} из {len(lots)}")
            data['lots'] = unsold_lots

        tg_msg = build_telegram_message(data, url, debtor_name, debtor_url)
        if not tg_msg:
            mark_second_group_processed(trade_number); handled.add(trade_number); continue
        try:
            logging.info(f"Telegram: отправка во вторую группу для {trade_number}")
            send_telegram_long(tg_msg, chat_id=TELEGRAM_SECOND_CHAT_ID)
            mark_second_group_processed(trade_number); handled.add(trade_number)
            logging.info(f"✓ Лот {trade_number} отправлен во вторую группу")
            time.sleep(2)
        except Exception as e:
            logging.error(f"Ошибка отправки лота {trade_number} во вторую группу: {e}")


# ──────────────────────────────────────────────
#  ДРАЙВЕР
# ──────────────────────────────────────────────
def create_driver(retries=3):
    """
    Создаёт ChromeDriver с повторными попытками.
    """
    options = uc.ChromeOptions()

    options.add_argument("--no-sandbox")
    options.add_argument("--disable-dev-shm-usage")
    options.add_argument("--window-size=1920,1080")
    options.add_argument("--disable-blink-features=AutomationControlled")
    options.add_argument("--disable-gpu")

    options.add_argument("--disable-extensions")
    options.add_argument("--disable-background-networking")
    options.add_argument("--disable-background-timer-throttling")
    options.add_argument("--disable-backgrounding-occluded-windows")
    options.add_argument("--disable-renderer-backgrounding")
    options.add_argument("--disable-features=TranslateUI")
    options.add_argument("--disable-ipc-flooding-protection")
    options.add_argument("--no-first-run")
    options.add_argument("--no-default-browser-check")
    options.add_argument("--metrics-recording-only")
    options.add_argument("--disable-hang-monitor")

    for attempt in range(1, retries + 1):
        try:
            driver = uc.Chrome(options=options, version_main=CHROME_VERSION)

            try:
                driver.set_page_load_timeout(60)
                driver.set_script_timeout(60)
            except Exception:
                pass

            return driver

        except Exception as e:
            err = str(e).lower()
            if any(kw in err for kw in (
                'name resolution', 'urlopen', 'urLError', 'dns',
                'temporary failure', 'connection', 'refused',
            )):
                logging.warning(
                    f"create_driver: попытка {attempt}/{retries} — "
                    f"сетевая ошибка: {e}"
                )

                if attempt < retries:
                    wait = 10 * attempt
                    logging.info(f"Повтор через {wait} сек…")
                    time.sleep(wait)
                    continue

            logging.error(f"create_driver: не удалось создать драйвер: {e}")
            raise


def is_driver_alive(driver):
    """Проверяет, жив ли ChromeDriver, и не завис ли рендерер страницы."""
    if driver is None:
        return False

    try:
        result = driver.execute_script("return document.readyState")
        if result not in (None, 'complete', 'interactive'):
            return False
        _ = driver.title
        return True
    except Exception:
        return False


def kill_driver(driver):
    """
    Безопасно убивает драйвер и ВСЕ дочерние процессы Chrome.
    """
    if driver is not None:
        try:
            driver.quit()
        except Exception:
            pass

    try:
        subprocess.run(
            ["pkill", "-9", "-f", "chrome"],
            capture_output=True, timeout=5,
        )
        subprocess.run(
            ["pkill", "-9", "-f", "chromedriver"],
            capture_output=True, timeout=5,
        )
    except Exception:
        pass

    try:
        for pattern in (
            "/tmp/rust_mozprofile*",
            "/tmp/.com.google.Chrome*",
            "/tmp/chrome_crashpad*",
        ):
            for path in glob.glob(pattern):
                subprocess.run(
                    ["rm", "-rf", path],
                    capture_output=True, timeout=5,
                )
    except Exception:
        pass

    time.sleep(2)


def ensure_driver(driver, force=False):
    if not force and is_driver_alive(driver):
        return driver

    logging.warning("Драйвер мёртв или требуется перезапуск — пересоздаём…")
    kill_driver(driver)
    time.sleep(3)
    return create_driver()


# ──────────────────────────────────────────────
#  ГЛАВНЫЙ ЦИКЛ
# ──────────────────────────────────────────────
if __name__ == "__main__":
    init_db()
    logging.info("Запуск объединённого парсера Fedresurs")

    display = None
    driver = None

    RECREATE_EVERY = 30
    cycle_count = 0
    last_second_scan = 0

    try:
        while True:
            try:
                if display is None:
                    display = Display(visible=0, size=(1920, 1080))
                    display.start()

                if driver is None or not is_driver_alive(driver):
                    driver = create_driver()

                cycle_count += 1
                cycle_start = time.time()

                force = (cycle_count % RECREATE_EVERY == 0)
                driver = ensure_driver(driver, force=force)

                # ═══ ШАГ 1: Сбор списка торгов ═══
                try:
                    logging.info("─── Шаг 1: Сбор списка торгов (TradeList) ───")
                    trades = fetch_trades(driver)

                    if trades:
                        save_to_db(trades, driver)
                    else:
                        logging.warning("Нет данных из TradeList")

                except Exception as e:
                    logging.error(f"Ошибка Шага 1: {e}")
                    driver = ensure_driver(driver, force=True)
                else:
                    driver = ensure_driver(driver)

                # ═══ ШАГ 2: Парсинг сообщений (лоты) ═══
                try:
                    logging.info("─── Шаг 2: Парсинг сообщений (лоты) ───")
                    process_messages(driver)

                except Exception as e:
                    logging.error(f"Ошибка Шага 2: {e}")
                    driver = ensure_driver(driver, force=True)
                else:
                    driver = ensure_driver(driver)

                # ═══ ШАГ 3: Почасовая проверка второй группы ═══
                if time.time() - last_second_scan >= SECOND_SCAN_INTERVAL:
                    try:
                        logging.info("─── Шаг 3: Проверка лотов для второй группы ───")
                        driver = ensure_driver(driver)
                        process_second_group(driver)

                    except Exception as e:
                        logging.error(f"Ошибка Шага 3: {e}")
                        driver = ensure_driver(driver, force=True)

                    finally:
                        last_second_scan = time.time()

                    driver = ensure_driver(driver)

                # ═══ Пауза ═══
                elapsed = time.time() - cycle_start
                logging.info(
                    f"Цикл завершён за {elapsed:.1f} сек. "
                    f"Ожидание {CYCLE_DELAY} сек…"
                )
                time.sleep(CYCLE_DELAY)

            except KeyboardInterrupt:
                logging.info("Остановка скрипта пользователем.")
                break

            except Exception as e:
                logging.exception(f"Неожиданная ошибка основного цикла: {e}")

                if driver is not None:
                    kill_driver(driver)
                driver = None

                if display is not None:
                    try:
                        display.stop()
                    except Exception:
                        pass
                display = None

                logging.warning("Перезапуск через 30 сек…")
                time.sleep(30)

    finally:
        if driver is not None:
            kill_driver(driver)

        if display is not None:
            try:
                display.stop()
            except Exception:
                pass