from __future__ import annotations

import json
import math
import re
import ctypes
from copy import copy
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from tkinter import Tk, StringVar, Toplevel, filedialog, messagebox
from tkinter import ttk, colorchooser

from openpyxl import Workbook, load_workbook
from openpyxl.cell.cell import MergedCell
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter


APP_DIR = Path(__file__).resolve().parent
CONFIG_PATH = APP_DIR / "plan_generator_config.json"
ALL_UNITS_LABEL = "Всі роти"


DAYS_UA = [
    "ПОНЕДІЛОК",
    "ВІВТОРОК",
    "СЕРЕДА",
    "ЧЕТВЕР",
    "П'ЯТНИЦЯ",
    "СУБОТА",
    "НЕДІЛЯ",
]


SUBJECT_DEFAULTS = [
    ("ТП", "Тактична підготовка"),
    ("ВП", "Вогнева підготовка"),
    ("Інж", "Інженерна підготовка"),
    ("Втоп", "Військова топографія"),
    ("ПсихП", "Психологічна підготовка"),
    ("РЕБ", "РЕБ"),
    ("Зв'язок", "Зв'язок та комунікація"),
    ("ТДДоп", "Тактична догоспітальна допомога"),
    ("РозвП", "Розвідувальна підготовка"),
    ("Вижив", "Виживання"),
    ("РХБЗ", "РХБ захист"),
]

SUBJECT_NAMES = dict(SUBJECT_DEFAULTS)
DETAIL_FILL = "FFD9D9D9"

SUBJECT_GRID_FILE_HINTS = {
    "Втоп": ["топограф"],
    "ПсихП": ["психолог"],
    "РЕБ": ["РЕБ"],
    "Зв'язок": ["Зв'язок", "Зв’язок", "комун"],
    "ТДДоп": ["догосп"],
    "РозвП": ["Розвідув"],
    "Вижив": ["Вижив"],
    "РХБЗ": ["РХБ"],
}


class POINT(ctypes.Structure):
    _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)]


SUBJECT_ALIASES = {
    "ВП": [r"\bВП\b", r"ВОГНЕВ"],
    "ТП": [r"\bТП\b", r"ТАКТИЧ"],
    "Інж": [r"\bІНЖ\b", r"ІНЖЕНЕР"],
    "Втоп": [r"\bВТОП(?:-Н)?\b", r"ТОПОГРАФ"],
    "ПсихП": [r"ПСИХ"],
    "РЕБ": [r"\bРЕБ\b"],
    "Зв'язок": [r"ЗВ[’'`ʼ]?ЯЗ", r"КОМУНІКАЦ"],
    "ТДДоп": [r"ТАКТ\.?\s*ДОГОСП", r"ДОГОСП"],
    "РозвП": [r"РОЗВІДУВ"],
    "Вижив": [r"ВИЖИВ"],
    "РХБЗ": [r"\bРХБЗ\b", r"РХБ\s*З", r"РХБ\s*ЗАХ"],
}


DAY_RE = re.compile(r"ДЕНЬ\s*[-№]?\s*(\d+)", re.IGNORECASE)


@dataclass
class Event:
    day_index: int
    part: str
    text: str
    subject_code: str | None
    day_no: int | None
    kind_label: str = ""   # normalized block type parsed from LG cell, e.g. "Практично"
    lesson_refs: tuple[tuple[int, int], ...] = ()
    absolute_day_index: int | None = None


# Canonical block-type labels and their fuzzy-match patterns.
# Handles common typos like ПРАКТИЧНИО, ТЕОРЕТИЧНО-ПРАКТИЧНО, etc.
_BLOCK_KIND_PATTERNS: list[tuple[str, list[str]]] = [
    ("Теоретично-практично", [r"ТЕОРЕТ\w*[\s\-/]+ПРАКТ\w*"]),
    ("Теоретично",           [r"^ТЕОРЕТ\w*$"]),
    ("Практично",            [r"^ПРАКТ\w*$"]),
]


def normalize_block_kind(raw: str) -> str:
    """Return a clean canonical block-type label, or the raw value if unknown."""
    upper = raw.strip().upper()
    if not upper:
        return ""
    for label, patterns in _BLOCK_KIND_PATTERNS:
        for pattern in patterns:
            if re.search(pattern, upper):
                return label
    # Fallback: return title-cased original
    return raw.strip().title()


@dataclass
class LessonDetails:
    title: str = ""
    place: str = ""
    kind_label: str = ""          # e.g. "Теоретично", "Практично", "Теоретично-практично"
    items: list[str] | None = None

    def __post_init__(self) -> None:
        if self.items is None:
            self.items = []


@dataclass
class DetailBlock:
    text: str
    kind: str = "body"
    fill: str | None = None
    span: int = 1


def norm_text(value) -> str:
    if value is None:
        return ""
    return str(value).replace("\r\n", "\n").replace("\r", "\n").strip()


def clean_spaces(text: str) -> str:
    return re.sub(r"[ \t]+", " ", norm_text(text))


def find_default_grid_path(code: str) -> str:
    hints = SUBJECT_GRID_FILE_HINTS.get(code, [])
    for hint in hints:
        hint_key = hint.casefold()
        for path in APP_DIR.glob("*.xlsx"):
            if path.name.startswith("~$"):
                continue
            if hint_key in path.name.casefold():
                return str(path)
    return ""


def screen_color_under_cursor() -> str:
    if not hasattr(ctypes, "windll"):
        raise RuntimeError("Піпетка доступна тільки у Windows.")

    point = POINT()
    user32 = ctypes.windll.user32
    gdi32 = ctypes.windll.gdi32
    if not user32.GetCursorPos(ctypes.byref(point)):
        raise RuntimeError("Не вдалося визначити позицію курсора.")

    hdc = user32.GetDC(0)
    if not hdc:
        raise RuntimeError("Не вдалося отримати екран для зчитування кольору.")
    try:
        color_ref = gdi32.GetPixel(hdc, point.x, point.y)
        if color_ref == -1:
            raise RuntimeError("Не вдалося зчитати колір під курсором.")
        red = color_ref & 0xFF
        green = (color_ref >> 8) & 0xFF
        blue = (color_ref >> 16) & 0xFF
        return f"{red:02X}{green:02X}{blue:02X}"
    finally:
        user32.ReleaseDC(0, hdc)


def merged_parent(ws, row: int, col: int):
    cell = ws.cell(row, col)
    if not isinstance(cell, MergedCell):
        return cell
    coord = cell.coordinate
    for merged in ws.merged_cells.ranges:
        if coord in merged:
            return ws.cell(merged.min_row, merged.min_col)
    return cell


def cell_value(ws, row: int, col: int):
    return merged_parent(ws, row, col).value


def copy_cell_format(src, dst) -> None:
    if src.has_style:
        dst.font = copy(src.font)
        dst.fill = copy(src.fill)
        dst.border = copy(src.border)
        dst.alignment = copy(src.alignment)
        dst.number_format = src.number_format
        dst.protection = copy(src.protection)


def set_border(ws, min_row: int, max_row: int, min_col: int, max_col: int) -> None:
    thin = Side(style="thin", color="000000")
    border = Border(left=thin, right=thin, top=thin, bottom=thin)
    for row in ws.iter_rows(min_row=min_row, max_row=max_row, min_col=min_col, max_col=max_col):
        for cell in row:
            cell.border = border


def fill_range(ws, min_row: int, max_row: int, min_col: int, max_col: int, color: str) -> None:
    fill = PatternFill("solid", fgColor=color.replace("#", "").upper())
    for row in ws.iter_rows(min_row=min_row, max_row=max_row, min_col=min_col, max_col=max_col):
        for cell in row:
            cell.fill = fill


def darken_color(color: str, factor: float = 0.72) -> str:
    raw = color.replace("#", "").upper()
    if len(raw) == 8:
        alpha, rgb = raw[:2], raw[2:]
    else:
        alpha, rgb = "FF", raw[-6:].rjust(6, "0")
    try:
        parts = [int(rgb[i : i + 2], 16) for i in range(0, 6, 2)]
    except ValueError:
        return raw
    dark = "".join(f"{max(0, min(255, int(part * factor))):02X}" for part in parts)
    return alpha + dark


def guess_subject(text: str, configured_codes: set[str]) -> str | None:
    upper = text.upper()
    normalized_codes = {code.upper(): code for code in configured_codes if code}
    for code, patterns in SUBJECT_ALIASES.items():
        if code.upper() not in normalized_codes:
            continue
        if any(re.search(pattern, upper, re.IGNORECASE) for pattern in patterns):
            return normalized_codes[code.upper()]
    for code_upper, original_code in normalized_codes.items():
        if original_code not in SUBJECT_ALIASES and code_upper in upper:
            return original_code
    return None


def parse_day_no(text: str) -> int | None:
    match = DAY_RE.search(text.upper())
    if not match:
        return None
    return int(match.group(1))


def parse_lesson_refs(text: str) -> tuple[tuple[int, int], ...]:
    """Extract short lesson references like "3/1" or "3/1 / 3/2"."""
    refs: list[tuple[int, int]] = []
    seen: set[tuple[int, int]] = set()
    for topic, lesson in re.findall(r"\b(\d{1,2})\s*/\s*(\d{1,2})\b", text):
        ref = (int(topic), int(lesson))
        if ref not in seen:
            seen.add(ref)
            refs.append(ref)
    return tuple(refs)


def parse_event_day_no(text: str, subject_code: str | None, lesson_refs: tuple[tuple[int, int], ...]) -> int | None:
    day_no = parse_day_no(text)
    if day_no is not None:
        return day_no
    # Psychological preparation in the LG is often written as "ПсихП 2/1"
    # instead of "ПсихП День 2". Use the first topic number as a lookup handle.
    if subject_code == "ПсихП" and lesson_refs:
        return lesson_refs[0][0]
    return None


def numeric_grid_day_keys(grid: dict[tuple, LessonDetails]) -> set[int]:
    return {
        key[0]
        for key in grid
        if isinstance(key, tuple) and len(key) == 3 and isinstance(key[0], int)
    }


def fallback_single_grid_day(grid: dict[tuple, LessonDetails]) -> int | None:
    day_numbers = numeric_grid_day_keys(grid)
    if len(day_numbers) == 1:
        return next(iter(day_numbers))
    return None


def lesson_refs_from_grid_text(text: str) -> tuple[tuple[int, int], ...]:
    refs: list[tuple[int, int]] = []
    seen: set[tuple[int, int]] = set()

    def add(topic: int, lesson: int) -> None:
        ref = (topic, lesson)
        if ref not in seen:
            seen.add(ref)
            refs.append(ref)

    for topic, first, last in re.findall(r"\b[тТ]\s*(\d{1,2})\s*[зЗ]\s*(\d{1,2})(?:\s*[-–]\s*(\d{1,2}))?", text):
        start = int(first)
        end = int(last) if last else start
        for lesson in range(start, end + 1):
            add(int(topic), lesson)

    if refs:
        return tuple(refs)

    topic_match = re.search(r"\bТема\s*(\d{1,2})\b", text, re.IGNORECASE)
    if topic_match:
        topic = int(topic_match.group(1))
        for first, last in re.findall(r"\bЗаняття\s*(\d{1,2})(?:\s*[-–]\s*(\d{1,2}))?", text, re.IGNORECASE):
            start = int(first)
            end = int(last) if last else start
            for lesson in range(start, end + 1):
                add(topic, lesson)

    return tuple(refs)


def parse_block_kind_from_lg(text: str) -> str:
    """Extract and normalize the block type written in an LG cell.

    LG cells look like:
        ВП День 1
        теоретично-практично   ← this line
        1/ 1,2
    We look for any line that matches ТЕОРЕТ or ПРАКТ (with fuzzy tolerance).
    Returns normalized label or "" if not found.
    """
    for line in text.splitlines():
        stripped = line.strip()
        if re.search(r"ТЕОРЕТ|ПРАКТ", stripped, re.IGNORECASE):
            # Make sure it's actually a kind line, not a topic/task line
            # (kind lines are short — usually under 40 chars and contain no digits like "ПВ-")
            if len(stripped) < 60 and not re.search(r"ПВ-?\d|КС\s", stripped, re.IGNORECASE):
                return normalize_block_kind(stripped)
    return ""


def subject_match_positions(text: str, configured_codes: set[str]) -> list[tuple[int, str]]:
    matches: list[tuple[int, str]] = []
    normalized_codes = {code.upper(): code for code in configured_codes if code}
    for code, patterns in SUBJECT_ALIASES.items():
        if code.upper() not in normalized_codes:
            continue
        for pattern in patterns:
            match = re.search(pattern, text, re.IGNORECASE)
            if match:
                matches.append((match.start(), normalized_codes[code.upper()]))
                break
    for code_upper, original_code in normalized_codes.items():
        if original_code not in SUBJECT_ALIASES:
            match = re.search(re.escape(original_code), text, re.IGNORECASE)
            if match:
                matches.append((match.start(), original_code))
    return sorted(matches, key=lambda item: item[0])


def split_schedule_cell(text: str, configured_codes: set[str]) -> list[str]:
    text = norm_text(text)
    if not text:
        return []

    lines = text.splitlines()
    blocks: list[list[str]] = []
    current: list[str] = []
    for line in lines:
        stripped = line.strip()
        starts_new = bool(stripped and current and subject_match_positions(stripped, configured_codes))
        if starts_new:
            blocks.append(current)
            current = [line]
        else:
            current.append(line)
    if current:
        blocks.append(current)

    result = [norm_text("\n".join(block)) for block in blocks if norm_text("\n".join(block))]
    return result or [text]


def find_date_row(ws) -> int | None:
    for row in range(1, min(ws.max_row, 25) + 1):
        count = sum(1 for cell in ws[row] if isinstance(cell.value, datetime))
        if count >= 6:
            return row
    return None


def week_option(ws, start_index: int, dates: list[datetime]) -> str:
    start = dates[start_index]
    end = dates[min(start_index + 6, len(dates) - 1)]
    week_no = start_index // 7 + 1
    return f"{ws.title} | тиждень {week_no} | {start.strftime('%d.%m.%Y')}-{end.strftime('%d.%m.%Y')}"


def find_week_context(workbook, selected: str):
    if "|" in selected:
        sheet_title, _, tail = selected.partition("|")
        ws = workbook[sheet_title.strip()]
        date_row = find_date_row(ws)
        if not date_row:
            raise ValueError("Не найдена строка с датами в выбранном графике.")
        day_cols = get_day_columns(ws, date_row)
        dates = [ws.cell(date_row, col).value for col in day_cols]
        week_match = re.search(r"тиждень\s+(\d+)", tail, re.IGNORECASE)
        week_no = int(week_match.group(1)) if week_match else 1
        start = (week_no - 1) * 7
        return ws, date_row, day_cols[start : start + 7]

    if selected in workbook.sheetnames:
        ws = workbook[selected]
        date_row = find_date_row(ws)
        if not date_row:
            raise ValueError("Не найдена строка с датами в выбранном листе.")
        day_cols = get_day_columns(ws, date_row)
        return ws, date_row, day_cols[:7]
    for ws in workbook.worksheets:
        date_row = find_date_row(ws)
        dates = [cell.value for cell in ws[date_row]] if date_row else []
        if dates and selected in ws.title:
            day_cols = get_day_columns(ws, date_row)
            return ws, date_row, day_cols[:7]
    raise ValueError(f"Не найден лист недели: {selected}")


def date_key(value) -> object:
    if isinstance(value, datetime):
        return value.date()
    return None


def selected_week_dates(ws, date_row: int, day_cols: list[int]) -> list[object]:
    return [date_key(ws.cell(date_row, col).value) for col in day_cols]


def find_week_context_by_dates(workbook, target_dates: list[object]):
    target_dates = [value for value in target_dates if value is not None]
    if len(target_dates) < 6:
        raise ValueError("Не вдалося визначити дати вибраної неділі.")

    target_len = len(target_dates)
    for ws in workbook.worksheets:
        date_row = find_date_row(ws)
        if not date_row:
            continue
        day_cols = get_day_columns(ws, date_row)
        dates = [date_key(ws.cell(date_row, col).value) for col in day_cols]
        for start in range(0, max(0, len(dates) - target_len) + 1):
            if dates[start : start + target_len] == target_dates:
                return ws, date_row, day_cols[start : start + target_len]
        for start in range(0, max(0, len(dates) - 7) + 1):
            window = dates[start : start + 7]
            if window and window[0] == target_dates[0] and window[-1] == target_dates[-1]:
                return ws, date_row, day_cols[start : start + 7]
    raise ValueError("Не знайдено вибраний період дат у цьому ЛГ.")


def get_day_columns(ws, date_row: int | None = None) -> list[int]:
    if date_row is None:
        date_row = find_date_row(ws) or 2
    cols = []
    for col in range(1, ws.max_column + 1):
        value = norm_text(ws.cell(date_row, col).value)
        if isinstance(ws.cell(date_row, col).value, datetime) or re.fullmatch(r"\d{1,2}\.\d{1,2}", value):
            cols.append(col)
    return cols


def find_unit_blocks(ws) -> list[tuple[str, int, int]]:
    blocks: list[tuple[str, int, int]] = []
    for merged in ws.merged_cells.ranges:
        if merged.min_col == 1 and merged.max_col == 1 and merged.min_row >= 3:
            value = norm_text(ws.cell(merged.min_row, 1).value)
            if value:
                blocks.append((value, merged.min_row, merged.max_row))
    if blocks:
        return sorted(blocks, key=lambda item: item[1])

    start = None
    name = ""
    for row in range(3, ws.max_row + 1):
        value = norm_text(ws.cell(row, 1).value)
        if value:
            if start is not None:
                blocks.append((name, start, row - 1))
            start = row
            name = value
    if start is not None:
        blocks.append((name, start, ws.max_row))
    return blocks


def unit_choices_for_week(lg_path: Path, week_sheet: str) -> list[str]:
    if not lg_path.exists() or not week_sheet:
        return []
    workbook = load_workbook(lg_path, read_only=False, data_only=True)
    ws, _date_row, _day_cols = find_week_context(workbook, week_sheet)
    choices: list[str] = [ALL_UNITS_LABEL]
    seen: set[str] = set()
    for unit_name, _start, _end in find_unit_blocks(ws):
        short_name = unit_name.split()[-1] if " " in unit_name else unit_name
        for value in (short_name, unit_name):
            if value and value not in seen:
                seen.add(value)
                choices.append(value)
    return choices


def normalize_selected_units(selected_units: list[str]) -> list[str]:
    cleaned = [item.strip() for item in selected_units if item.strip()]
    if any(item.casefold() == ALL_UNITS_LABEL.casefold() for item in cleaned):
        return []
    return cleaned


def unit_sort_key(unit_name: str, short_name: str) -> tuple[int, str]:
    match = re.search(r"\d+", short_name or unit_name)
    return (int(match.group(0)) if match else 9999, short_name or unit_name)


def unit_matches(unit_name: str, short_name: str, selected_normalized: list[str], selected_number_only: list[str]) -> bool:
    if not selected_normalized and not selected_number_only:
        return True
    unit_key = unit_name.upper().replace(" ", "")
    short_key = short_name.upper().replace(" ", "")
    if unit_key in selected_normalized or short_key in selected_normalized:
        return True
    short_numbers = re.findall(r"\d+", short_name)
    return bool(selected_number_only and any(number in short_numbers for number in selected_number_only))


def read_schedule(ws, selected_units: list[str], grid_codes: set[str], date_row: int | None = None, day_cols: list[int] | None = None):
    if date_row is None:
        date_row = find_date_row(ws) or 2
    if day_cols is None:
        day_cols = get_day_columns(ws, date_row)[:7]
    if len(day_cols) < 6:
        raise ValueError("Не удалось найти даты недели в линейном графике.")

    dates = []
    for col in day_cols:
        value = ws.cell(date_row, col).value
        if isinstance(value, datetime):
            dates.append(value)
        else:
            dates.append(None)

    selected_units = normalize_selected_units(selected_units)
    units_by_key: dict[str, tuple[str, str, list[Event], set[tuple[int, str, str]]]] = {}
    units_order: list[str] = []
    selected_normalized = [u.upper().replace(" ", "") for u in selected_units if u.strip()]
    selected_number_only = [item.strip() for item in selected_units if re.fullmatch(r"\d+", item.strip())]
    for unit_name, start, end in find_unit_blocks(ws):
        short_name = unit_name.split()[-1] if " " in unit_name else unit_name
        if not unit_matches(unit_name, short_name, selected_normalized, selected_number_only):
            continue

        unit_key = unit_name.upper().replace(" ", "")
        if unit_key not in units_by_key:
            units_by_key[unit_key] = (unit_name, short_name, [], set())
            units_order.append(unit_key)
        _stored_name, _stored_short, events, seen_events = units_by_key[unit_key]
        seen = set()
        for row in range(start, end + 1):
            num = ws.cell(row, 2).value
            base_part = "day" if isinstance(num, (int, float)) and num <= 6 else "night"
            for day_index, col in enumerate(day_cols):
                parent = merged_parent(ws, row, col)
                key = parent.coordinate
                if key in seen:
                    continue
                seen.add(key)
                text = norm_text(parent.value)
                if not text:
                    continue
                for event_text in split_schedule_cell(text, grid_codes):
                    upper = event_text.upper()
                    if "ВНОЧ" in upper or "НІЧ" in upper:
                        part = "night"
                    elif "ДЕНЬ" in upper or "ВДЕНЬ" in upper:
                        part = "day"
                    else:
                        part = base_part
                    subject = guess_subject(event_text, grid_codes)
                    lesson_refs = parse_lesson_refs(event_text)
                    event_key = (day_index, part, event_text)
                    if event_key in seen_events:
                        continue
                    seen_events.add(event_key)
                    events.append(
                        Event(
                            day_index=day_index,
                            part=part,
                            text=event_text,
                            subject_code=subject,
                            day_no=parse_event_day_no(event_text, subject, lesson_refs),
                            kind_label=parse_block_kind_from_lg(event_text),
                            lesson_refs=lesson_refs,
                        )
                    )
    units = [(units_by_key[key][0], units_by_key[key][1], units_by_key[key][2]) for key in units_order]
    return dates, units


def schedule_day_columns_for_sheet(ws, date_row: int, selected_day_cols: list[int] | None = None) -> list[int]:
    all_day_cols = get_day_columns(ws, date_row)
    if selected_day_cols and len(all_day_cols) > len(selected_day_cols):
        return all_day_cols
    return selected_day_cols or all_day_cols[:7]


def psych_day_map_from_lg(ws, date_row: int, selected_units: list[str], grid_codes: set[str], selected_day_cols: list[int]) -> tuple[dict[int, int], dict[int, int]]:
    if "ПсихП" not in grid_codes:
        return {}, {}
    all_day_cols = schedule_day_columns_for_sheet(ws, date_row, selected_day_cols)
    _dates, all_units = read_schedule(ws, selected_units, grid_codes, date_row, all_day_cols)

    psych_day_by_date_index: dict[int, int] = {}
    next_psych_day = 1
    for absolute_index in range(len(all_day_cols)):
        has_psych = any(
            event.subject_code == "ПсихП" and event.part == "day"
            for _unit_full, _unit_short, events in all_units
            for event in events
            if event.day_index == absolute_index
        )
        if has_psych:
            psych_day_by_date_index[absolute_index] = next_psych_day
            next_psych_day += 1

    selected_col_to_absolute = {col: index for index, col in enumerate(all_day_cols)}
    selected_local_to_absolute = {
        local_index: selected_col_to_absolute[col]
        for local_index, col in enumerate(selected_day_cols)
        if col in selected_col_to_absolute
    }
    return psych_day_by_date_index, selected_local_to_absolute


def apply_psych_days(
    units: list[tuple[str, str, list[Event]]],
    psych_day_by_date_index: dict[int, int],
    selected_local_to_absolute: dict[int, int],
) -> None:
    if not psych_day_by_date_index:
        return
    for _unit_full, _unit_short, events in units:
        for event in events:
            if event.subject_code != "ПсихП":
                continue
            absolute_index = selected_local_to_absolute.get(event.day_index)
            event.absolute_day_index = absolute_index
            event.day_no = psych_day_by_date_index.get(absolute_index, event.day_no)



def find_day_columns_in_grid(ws) -> dict[int, int]:
    """Return {day_no: col} mapping. When a day number appears in multiple columns
    (e.g. 'День 1' in a теоретично block AND a практично block), the first
    occurrence is stored — the block-kind key in the grid dict distinguishes them."""
    result: dict[int, int] = {}
    for row in range(1, min(ws.max_row, 8) + 1):
        for col in range(1, ws.max_column + 1):
            text = norm_text(ws.cell(row, col).value).upper()
            match = DAY_RE.search(text)
            if match:
                day_no = int(match.group(1))
                if day_no not in result:   # keep FIRST occurrence
                    result[day_no] = col
    return result


def find_all_day_columns_in_grid(ws) -> list[tuple[int, int]]:
    """Return ALL (day_no, col) pairs including duplicate day numbers.
    Used when a grid has the same day repeated in different block columns."""
    result: list[tuple[int, int]] = []
    seen_cols: set[int] = set()
    for row in range(1, min(ws.max_row, 8) + 1):
        for col in range(1, ws.max_column + 1):
            if col in seen_cols:
                continue
            text = norm_text(ws.cell(row, col).value).upper()
            match = DAY_RE.search(text)
            if match:
                result.append((int(match.group(1)), col))
                seen_cols.add(col)
    return result


def section_rows(ws) -> tuple[int | None, int | None]:
    marker_row = 0
    for row in range(1, ws.max_row + 1):
        for col in range(1, ws.max_column + 1):
            text = norm_text(ws.cell(row, col).value).upper()
            if re.search(r"НАВЧАЛЬН\w*\s+(МІСЦЯ|МЕСТА)\b", text):
                marker_row = row
                break
        if marker_row:
            break

    day_row = None
    night_row = None
    for row in range(marker_row + 1, ws.max_row + 1):
        row_text = " ".join(norm_text(ws.cell(row, c).value).upper() for c in range(1, min(ws.max_column, 5) + 1))
        if "ВДЕНЬ" in row_text and day_row is None:
            day_row = row
        if "ВНОЧ" in row_text and night_row is None:
            night_row = row

    if marker_row and day_row is None and night_row is None:
        for row in range(marker_row + 1, ws.max_row + 1):
            row_text = " ".join(norm_text(ws.cell(row, c).value).strip() for c in range(1, min(ws.max_column, 5) + 1))
            if row_text.strip():
                day_row = row
                break

    if marker_row and night_row is not None and day_row is None and night_row > marker_row + 1:
        day_row = marker_row + 1

    return day_row, night_row


def find_kind_label_row(ws, day_col_row: int, day_cols: dict[int, int]) -> int | None:
    """Return the row that contains the block-type labels (ТЕОРЕТИЧНО/ПРАКТИЧНО etc.)

    The kind row can appear either above or below the day-header row.
    We prefer the row where the most day-columns have a recognisable kind value.
    """
    kind_pattern = re.compile(r"ТЕОРЕТ|ПРАКТ", re.IGNORECASE)
    candidate_rows: list[tuple[int, int]] = []  # (coverage_count, row)
    search_range = range(max(1, day_col_row - 4), min(ws.max_row, day_col_row + 5))
    for row in search_range:
        if row == day_col_row:
            continue
        coverage = sum(
            1 for col in day_cols.values()
            if kind_pattern.search(norm_text(merged_parent(ws, row, col).value))
        )
        if coverage > 0:
            candidate_rows.append((coverage, row))
    if not candidate_rows:
        return None
    # Return row with best column coverage; on tie prefer closer to day_col_row
    candidate_rows.sort(key=lambda item: (-item[0], abs(item[1] - day_col_row)))
    return candidate_rows[0][1]


def read_block_kinds(ws, kind_row: int, day_cols: dict[int, int]) -> dict[int, str]:
    """Read block-type label for each day column. Falls back to empty string."""
    result: dict[int, str] = {}
    if not kind_row:
        return result
    for day_no, col in day_cols.items():
        raw = norm_text(merged_parent(ws, kind_row, col).value)
        result[day_no] = normalize_block_kind(raw) if raw else ""
    return result


def read_grid(path: Path) -> dict[tuple, LessonDetails]:
    wb = load_workbook(path, data_only=False)
    details: dict[tuple, LessonDetails] = {}
    for ws in wb.worksheets:
        all_day_cols = find_all_day_columns_in_grid(ws)
        if not all_day_cols:
            continue

        # Determine the row that contains day headers
        day_header_row: int | None = None
        for row in range(1, min(ws.max_row, 10) + 1):
            for col in range(1, ws.max_column + 1):
                if DAY_RE.search(norm_text(ws.cell(row, col).value)):
                    day_header_row = row
                    break
            if day_header_row:
                break

        # Build col→day_no lookup for kind-row reading
        col_to_day: dict[int, int] = {col: day_no for day_no, col in all_day_cols}
        day_cols_dict: dict[int, int] = {}
        for day_no, col in all_day_cols:
            day_cols_dict[col] = day_no  # col→day_no (all cols)

        # Find kind label row using all columns
        all_cols_set = {col: col for col in col_to_day}
        kind_row = find_kind_label_row(ws, day_header_row, all_cols_set) if day_header_row else None

        # Read per-column kind labels
        col_kind: dict[int, str] = {}
        if kind_row:
            kind_pattern = re.compile(r"ТЕОРЕТ|ПРАКТ", re.IGNORECASE)
            for col in col_to_day:
                raw = norm_text(merged_parent(ws, kind_row, col).value)
                col_kind[col] = normalize_block_kind(raw) if kind_pattern.search(raw) else ""

        day_section, night_section = section_rows(ws)

        for day_no, col in all_day_cols:
            title = clean_spaces(cell_value(ws, 1, 1) or "")
            topic_row = best_topic_row(ws, day_header_row, col)
            topic = clean_spaces(cell_value(ws, topic_row, col) or cell_value(ws, topic_row - 1, col) or "")
            if topic:
                title = topic
            kind_label = col_kind.get(col, "")
            topic_titles: list[str] = []
            if day_header_row:
                for candidate_row in range(day_header_row + 2, ws.max_row + 1):
                    row_marker = clean_spaces(cell_value(ws, candidate_row, 1) or "").upper()
                    if "НАВЧАЛЬНІ МІСЦЯ" in row_marker or "НАВЧАЛЬНЫЕ МЕСТА" in row_marker:
                        break
                    topic_text = clean_spaces(cell_value(ws, candidate_row, col) or "")
                    topic_upper = topic_text.upper()
                    if topic_text and "ТЕМА" in topic_upper and "ЗАНЯТТЯ" in topic_upper and topic_text not in topic_titles:
                        topic_titles.append(topic_text)
            if not topic_titles and title:
                topic_titles.append(title)

            for part, start_row, stop_row in [
                ("day", day_section, night_section - 1 if day_section and night_section else ws.max_row),
                ("night", night_section, ws.max_row),
            ]:
                if not start_row:
                    continue
                items = []
                seen_cells = set()
                for row in range(start_row, stop_row + 1):
                    parent = merged_parent(ws, row, col)
                    if parent.coordinate in seen_cells:
                        continue
                    seen_cells.add(parent.coordinate)
                    text = norm_text(parent.value)
                    if text and "НАВЧАЛЬНІ МІСЦЯ" not in text.upper():
                        items.append(text)
                first_place = next(
                    (
                        index
                        for index, item in enumerate(items)
                        if "НАВЧАЛЬНЕ МІСЦЕ" in item.upper() or "НАВЧАЛЬНА ТОЧКА" in item.upper()
                    ),
                    None,
                )
                if first_place is not None:
                    items = items[first_place:]
                if items or title:
                    key = (day_no, part, kind_label)
                    current = details.get(key)
                    candidate = LessonDetails(title=title, kind_label=kind_label, items=items)
                    if current is None or len(candidate.items or []) > len(current.items or []):
                        details[key] = candidate
                    for topic_title in topic_titles:
                        topic_candidate = LessonDetails(title=topic_title, kind_label=kind_label, items=items)
                        for topic_no, lesson_no in lesson_refs_from_grid_text(topic_title):
                            candidates_key = ("lesson", topic_no, lesson_no)
                            details.setdefault(candidates_key, [])
                            existing = details[candidates_key]
                            if isinstance(existing, list):
                                existing.append((part, kind_label, topic_candidate))
    return details


def read_all_grids(grid_paths: dict[str, str]) -> dict[str, dict[tuple, LessonDetails]]:
    grids = {}
    for code, path_text in grid_paths.items():
        path = Path(path_text)
        if code.strip() and path.exists():
            grids[code.strip()] = read_grid(path)
    return grids


def best_topic_row(ws, day_header_row: int | None, col: int) -> int:
    if not day_header_row:
        return 7

    start = day_header_row + 1
    stop = min(ws.max_row, day_header_row + 10)
    preferred: list[int] = []
    fallback: list[int] = []
    skip_pattern = re.compile(
        r"ТЕОРЕТИЧНО|ПРАКТИЧНО|КІЛЬКІСТЬ\s*ГОДИН|КУРС|^ДЕНЬ\b|ЛІНІЙНИЙ\s+ГРАФІК|НАВЧАЛЬН[ІI]\s+МІСЦЯ",
        re.IGNORECASE,
    )
    topic_pattern = re.compile(r"\bТЕМ[АИ]\b|ЗАНЯТТЯ", re.IGNORECASE)
    for row in range(start, stop + 1):
        text = clean_spaces(cell_value(ws, row, col) or "")
        if not text:
            continue
        upper = text.upper()
        if skip_pattern.search(upper):
            continue
        if topic_pattern.search(text):
            preferred.append(row)
        else:
            fallback.append(row)
    if preferred:
        return preferred[0]
    if fallback:
        return fallback[0]
    return day_header_row + 2


def lesson_from_event(event: Event, grids) -> LessonDetails | None:
    if not (event.subject_code and event.subject_code in grids):
        return None
    grid = grids[event.subject_code]
    day_no = event.day_no or fallback_single_grid_day(grid)
    if event.subject_code == "ПсихП" and event.lesson_refs:
        lesson_candidates: list[tuple[int, int, str, str, LessonDetails]] = []
        for topic_no, lesson_no in event.lesson_refs:
            candidates = grid.get(("lesson", topic_no, lesson_no), [])
            if isinstance(candidates, list):
                for part, kind, detail in candidates:
                    lesson_candidates.append((topic_no, lesson_no, part, kind, detail))
        if lesson_candidates:
            def score(candidate: tuple[int, int, str, str, LessonDetails]) -> tuple[int, int, int]:
                _topic_no, _lesson_no, part, kind, detail = candidate
                part_penalty = 0 if part == event.part else 1
                kind_penalty = 0 if event.kind_label and kind == event.kind_label else (1 if event.kind_label else 0)
                haystack = f"{detail.title}\n" + "\n".join(detail.items or [])
                words = [
                    word.casefold()
                    for word in re.findall(r"[A-Za-zА-Яа-яІіЇїЄєҐґ]{4,}", event.text)
                    if word.casefold() not in {"психп", "практично", "теоретично"}
                ]
                overlap = sum(1 for word in set(words) if word in haystack.casefold())
                return (part_penalty, kind_penalty, -overlap)

            return sorted(lesson_candidates, key=score)[0][4]
    if day_no is None:
        return None
    # Try exact match: (day_no, part, kind_label)
    if event.kind_label:
        detail = grid.get((day_no, event.part, event.kind_label))
        if detail:
            return detail
    # Fallback 1: same day+part, matching kind_label (strict kind match across day/night)
    if event.kind_label:
        for key, detail in grid.items():
            if not (isinstance(key, tuple) and len(key) == 3):
                continue
            candidate_day_no, _part, kind = key
            if candidate_day_no == day_no and kind == event.kind_label:
                return detail
    # Fallback 2: same day+part, any kind — but only if there is a single kind in the grid for this day
    candidates = [(k, v) for k, v in grid.items() if isinstance(k, tuple) and len(k) == 3 and k[0] == day_no and k[1] == event.part]
    if len(candidates) == 1:
        return candidates[0][1]
    if candidates:
        # Multiple kinds: prefer the one whose kind_label best matches event.kind_label
        if event.kind_label:
            scored = sorted(candidates, key=lambda item: (0 if item[0][2] == event.kind_label else 1))
            return scored[0][1]
        return candidates[0][1]
    # Fallback 3: same day, any part, single kind
    day_candidates = [(k, v) for k, v in grid.items() if isinstance(k, tuple) and len(k) == 3 and k[0] == day_no]
    if len(day_candidates) == 1:
        return day_candidates[0][1]
    if day_candidates:
        if event.kind_label:
            scored = sorted(day_candidates, key=lambda item: (0 if item[0][2] == event.kind_label else 1))
            return scored[0][1]
        return day_candidates[0][1]
    return None


def ref_match_count(refs: set[tuple[int, int]], text: str) -> int:
    return len(refs.intersection(set(lesson_refs_from_grid_text(text))))


def psych_lesson_from_group(events: list[Event], grids) -> LessonDetails | None:
    grid = grids.get("ПсихП")
    if not grid:
        return None

    day_numbers = [event.day_no for event in events if event.day_no]
    if day_numbers:
        day_no = day_numbers[0]
        candidates = [(key, detail) for key, detail in grid.items() if isinstance(key, tuple) and len(key) == 3 and key[0] == day_no and key[1] == events[0].part]
        if candidates:
            if events[0].kind_label:
                scored = sorted(candidates, key=lambda item: (0 if item[0][2] == events[0].kind_label else 1))
                return scored[0][1]
            return candidates[0][1]

    combined_text = "\n".join(event.text for event in events)
    refs = {ref for event in events for ref in event.lesson_refs}
    words = {
        word.casefold()
        for word in re.findall(r"[A-Za-zА-Яа-яІіЇїЄєҐґ]{4,}", combined_text)
        if word.casefold() not in {"психп", "практично", "теоретично", "день"}
    }

    candidates: list[tuple[int, int, LessonDetails]] = []
    for key, detail in grid.items():
        if not (isinstance(key, tuple) and len(key) == 3 and isinstance(key[0], int) and key[1] == events[0].part):
            continue
        haystack = f"{detail.title}\n" + "\n".join(detail.items or [])
        haystack_key = haystack.casefold()
        word_score = 0
        for word in words:
            if word in haystack_key:
                word_score += 1
            elif len(word) >= 5 and word[:5] in haystack_key:
                word_score += 1
        ref_score = ref_match_count(refs, haystack)
        kind_score = 1 if events[0].kind_label and detail.kind_label == events[0].kind_label else 0
        score = word_score * 20 + ref_score * 2 + kind_score
        candidates.append((score, -key[0], detail))

    if not candidates:
        return None
    candidates.sort(reverse=True, key=lambda item: (item[0], item[1]))
    if candidates[0][0] <= 0:
        return None
    return candidates[0][2]


def format_title(event: Event, details: LessonDetails) -> str:
    if event.subject_code:
        subject = SUBJECT_NAMES.get(event.subject_code, event.subject_code)
        part = "Вночі" if event.part == "night" else "Вдень"
        kind = f" ({details.kind_label})" if details.kind_label else ""
        ref_text = ", ".join(f"{topic}/{lesson}" for topic, lesson in event.lesson_refs) if event.subject_code == "ПсихП" else ""
        lesson_label = ref_text or (f"День {event.day_no}" if event.day_no else "")
        middle = f" {lesson_label}" if lesson_label else ""
        return f"Заняття:\n{subject}{middle} {part}{kind}"
    return f"Заняття:\n{event.text}"


def format_combined_title(events: list[Event], details: list[LessonDetails]) -> str:
    if len(events) == 1 and details:
        return format_title(events[0], details[0])
    first = events[0]
    subject = SUBJECT_NAMES.get(first.subject_code or "", first.subject_code or "")
    part = "Вночі" if first.part == "night" else "Вдень"

    refs: list[tuple[int, int]] = []
    seen_refs: set[tuple[int, int]] = set()
    if first.subject_code == "ПсихП":
        for event in events:
            for ref in event.lesson_refs:
                if ref not in seen_refs:
                    seen_refs.add(ref)
                    refs.append(ref)

    day_numbers: list[int] = []
    seen_days: set[int] = set()
    for event in events:
        if event.day_no and event.day_no not in seen_days:
            seen_days.add(event.day_no)
            day_numbers.append(event.day_no)

    ref_text = ", ".join(f"{topic}/{lesson}" for topic, lesson in refs)
    if ref_text:
        lesson_label = ref_text
    elif day_numbers:
        lesson_label = ", ".join(f"День {day_no}" for day_no in day_numbers)
    else:
        lesson_label = ""

    kinds = [detail.kind_label for detail in details if detail.kind_label]
    kind = f" ({kinds[0]})" if kinds and all(item == kinds[0] for item in kinds) else ""
    middle = f" {lesson_label}" if lesson_label else ""
    return f"Заняття:\n{subject}{middle} {part}{kind}"


def append_detail_lesson(
    blocks: list[DetailBlock],
    events: list[Event],
    details: list[LessonDetails],
    title_fill: str,
    item_fill: str | None,
) -> None:
    blocks.append(DetailBlock(format_combined_title(events, details), "detail_title", title_fill))
    blocks.append(DetailBlock("Місце проведення:", "place", title_fill))
    items_seen: set[str] = set()
    for detail in details:
        for item in detail.items or []:
            for point in split_training_points(item):
                if point not in items_seen:
                    items_seen.add(point)
                    blocks.append(DetailBlock(point, "body", item_fill, detail_span(point)))


def detail_event_groups(events: list[Event]) -> list[list[Event]]:
    groups: list[list[Event]] = []
    index = 0
    while index < len(events):
        event = events[index]
        group = [event]
        index += 1
        if event.subject_code == "ПсихП":
            while index < len(events) and events[index].subject_code == "ПсихП":
                group.append(events[index])
                index += 1
        groups.append(group)
    return groups


def format_summary_event(event: Event) -> str:
    if event.subject_code and event.day_no:
        ref_text = ", ".join(f"{topic}/{lesson}" for topic, lesson in event.lesson_refs) if event.subject_code == "ПсихП" else ""
        lines = [f"{event.subject_code} {ref_text or f'День {event.day_no}'}"]
        for line in event.text.splitlines():
            clean = clean_spaces(line)
            upper = clean.upper()
            if not clean or "ДЕНЬ" in upper:
                continue
            if "ПРАКТИЧ" in upper or "ТЕОРЕТ" in upper or "НІЧ" in upper or "ВНОЧ" in upper:
                lines.append(clean)
        return "\n".join(dict.fromkeys(lines))

    skip_words = ["ЗВС", "ТИР", "ППД", "МТП", "ДНР", "ТАНКОВА ДИРЕКТРИСА", "ЛАЗЕРТАГ"]
    lines = []
    for line in event.text.splitlines():
        clean = clean_spaces(line)
        if clean and not any(word in clean.upper() for word in skip_words):
            lines.append(clean)
    return "\n".join(lines) or event.text


def format_summary_events(events: list[Event]) -> str:
    parts: list[str] = []
    for group in detail_event_groups(events):
        if len(group) == 1 or group[0].subject_code != "ПсихП":
            parts.append(format_summary_event(group[0]))
            continue

        refs: list[tuple[int, int]] = []
        seen_refs: set[tuple[int, int]] = set()
        for event in group:
            for ref in event.lesson_refs:
                if ref not in seen_refs:
                    seen_refs.add(ref)
                    refs.append(ref)
        ref_text = ", ".join(f"{topic}/{lesson}" for topic, lesson in refs)
        lines = [f"{group[0].subject_code} {ref_text}".strip()]
        for event in group:
            for line in event.text.splitlines():
                clean = clean_spaces(line)
                upper = clean.upper()
                if not clean or "ДЕНЬ" in upper:
                    continue
                if "ПРАКТИЧ" in upper or "ТЕОРЕТ" in upper or "НІЧ" in upper or "ВНОЧ" in upper:
                    lines.append(clean)
        parts.append("\n".join(dict.fromkeys(lines)))
    return "\n\n".join(part for part in parts if part)


def summary_event_groups(events: list[Event]) -> list[list[Event]]:
    return detail_event_groups(visible_summary_events(events))


def format_summary_group(group: list[Event]) -> str:
    if len(group) == 1 or group[0].subject_code != "ПсихП":
        return format_summary_event(group[0])

    refs: list[tuple[int, int]] = []
    seen_refs: set[tuple[int, int]] = set()
    for event in group:
        for ref in event.lesson_refs:
            if ref not in seen_refs:
                seen_refs.add(ref)
                refs.append(ref)
    ref_text = ", ".join(f"{topic}/{lesson}" for topic, lesson in refs)
    lines = [f"{group[0].subject_code} {ref_text}".strip()]
    for event in group:
        for line in event.text.splitlines():
            clean = clean_spaces(line)
            upper = clean.upper()
            if not clean or "ДЕНЬ" in upper:
                continue
            if "ПРАКТИЧ" in upper or "ТЕОРЕТ" in upper or "НІЧ" in upper or "ВНОЧ" in upper:
                lines.append(clean)
    return "\n".join(dict.fromkeys(lines))


def summary_group_fill(group: list[Event], subject_colors: dict[str, str], default_color: str) -> str:
    if not group:
        return default_color
    return subject_colors.get(group[0].subject_code or "", default_color)


def parse_day_order(order_text: str) -> list[int]:
    if not order_text.strip():
        return list(range(7))
    numbers = [int(value) for value in re.findall(r"\d+", order_text)]
    if len(numbers) != 7 or sorted(numbers) != list(range(1, 8)):
        raise ValueError("Порядок дней должен содержать числа 1-7 без повторов, например: 1,2,3,4,5,6,7")
    return [number - 1 for number in numbers]


def day_name_for_date(dates: list[datetime | None], index: int) -> str:
    if 0 <= index < len(dates) and isinstance(dates[index], datetime):
        return DAYS_UA[dates[index].weekday()]
    return DAYS_UA[index] if 0 <= index < len(DAYS_UA) else ""


def day_date_text(dates: list[datetime | None], index: int) -> str:
    if 0 <= index < len(dates) and isinstance(dates[index], datetime):
        return dates[index].strftime("%d.%m")
    return ""


def visible_summary_events(events: list[Event]) -> list[Event]:
    recognized = [event for event in events if event.subject_code and event.day_no]
    return recognized or events


def split_training_points(text: str) -> list[str]:
    text = norm_text(text)
    if not text:
        return []

    patterns = [
        r"(?=\n?\s*НТ\s*№?\s*[2-9]\d*[\.\:\s])",
        r"(?=\n?\s*Навчальна\s+точка\s*№\s*[2-9]\d*[\.\s\(])",
        r"(?=\n?\s*Навчальна\s+точка\s*№\s*[2-9]\d*\b)",
    ]
    parts = [text]
    for pattern in patterns:
        next_parts = []
        for part in parts:
            next_parts.extend(re.split(pattern, part, flags=re.IGNORECASE))
        parts = next_parts

    cleaned = [clean_spaces(part) for part in parts if clean_spaces(part)]
    return cleaned or [text]


def estimate_height(texts: list[str], width: float, min_height: float = 34.5,
                    font_size: float = 10.0) -> float:
    """Estimate row height in points based on text content and font size.
    Use a more accurate word-wrap estimate and a tighter padding value.
    Excel column width unit is approximated in points for a default font.
    Row height in OpenPyXL is measured in points."""
    line_height_pt = font_size * 1.35
    avg_char_width_pt = font_size * 0.58
    col_width_pt = width * 7.0
    chars_per_line = max(8, int(col_width_pt / avg_char_width_pt))
    total_lines = 0

    def wrapped_lines(line: str) -> int:
        if not line:
            return 1
        words = line.split()
        if not words:
            return 1
        line_len = 0
        lines = 1
        for word in words:
            word_len = len(word)
            if line_len == 0:
                line_len = word_len
            elif line_len + 1 + word_len <= chars_per_line:
                line_len += 1 + word_len
            else:
                lines += 1
                line_len = word_len
        return lines

    for text in texts:
        if not text:
            continue
        for line in str(text).splitlines() or [""]:
            total_lines += wrapped_lines(line)
    if total_lines == 0:
        total_lines = 1
    height_pt = total_lines * line_height_pt + 4
    return min(409, max(min_height, height_pt))


def detail_span(text: str) -> int:
    if not text:
        return 1
    estimated = estimate_height([text], 43, 13.0, 10.0)
    if estimated > 200:
        return 4
    if estimated > 140:
        return 3
    if estimated > 75:
        return 2
    return 1


def prepare_sheet(template_path: Path | None) -> Workbook:
    if template_path and template_path.exists():
        template_wb = load_workbook(template_path)
        template_ws = template_wb.worksheets[0]
        wb = Workbook()
        ws = wb.active
        ws.title = "План"
        for col in range(1, 9):
            letter = get_column_letter(col)
            ws.column_dimensions[letter].width = template_ws.column_dimensions[letter].width or 13
        return wb
    wb = Workbook()
    wb.active.title = "План"
    return wb


def apply_common_style(ws, row: int, col: int, kind: str = "body") -> None:
    cell = ws.cell(row, col)
    if kind == "header":
        cell.font = Font(name="Inter Bold", size=28)
        cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
    elif kind == "header_small":
        cell.font = Font(name="Inter Bold", size=16)
        cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
    elif kind == "unit":
        cell.font = Font(name="Inter Bold", size=24)
        cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
    elif kind == "summary":
        cell.font = Font(name="Inter Bold", size=20)
        cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
    elif kind == "detail_title":
        cell.font = Font(name="Times New Roman", size=16)
        cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
    elif kind == "place":
        cell.font = Font(name="Times New Roman", size=16)
        cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
    else:
        cell.font = Font(name="Times New Roman", size=10)
        cell.alignment = Alignment(horizontal="left", vertical="top", wrap_text=True)


SCHEDULE_FILL = "FFFF0000"
SCHEDULE_WIDTHS = {
    "A": 17.42578125,
    "B": 0.28515625,
    "C": 7.7109375,
    "D": 3.0,
    "E": 2.7109375,
    "F": 9.0,
    "G": 13.0,
    "H": 13.0,
    "I": 13.0,
    "J": 13.0,
    "K": 13.0,
    "L": 6.7109375,
    "M": 9.0,
    "N": 8.42578125,
    "O": 9.0,
    "P": 24.42578125,
    "Q": 7.42578125,
    "R": 25.28515625,
}


def setup_lesson_schedule_sheet(ws) -> None:
    ws.page_setup.orientation = "landscape"
    ws.page_setup.fitToWidth = 2
    ws.sheet_properties.pageSetUpPr.fitToPage = True
    ws.page_margins.left = 0.196850393700787
    ws.page_margins.right = 0.196850393700787
    ws.page_margins.top = 0.196850393700787
    ws.page_margins.bottom = 0.196850393700787
    for letter, width in SCHEDULE_WIDTHS.items():
        ws.column_dimensions[letter].width = width
    ws.column_dimensions["B"].hidden = True
    ws.column_dimensions["C"].hidden = True


def normalize_fill_color(color: str) -> str:
    raw = color.replace("#", "").upper().strip()
    if len(raw) == 6:
        return "FF" + raw
    return raw or SCHEDULE_FILL


def lesson_schedule_span(text: str, kind: str) -> int:
    if kind != "lesson":
        return 1
    length = len(clean_spaces(text))
    if length > 520:
        return 3
    if length > 260:
        return 2
    return 1


def apply_lesson_schedule_block(ws, start_row: int, end_row: int, kind: str, fill_color: str) -> None:
    thin = Side(style="thin", color="000000")
    border = Border(left=thin, right=thin, top=thin, bottom=thin)
    for row in range(start_row, end_row + 1):
        ws.row_dimensions[row].height = 14.25 if row in (1, 2) else 14.1
        for col in range(1, 19):
            cell = ws.cell(row, col)
            cell.font = Font(name="Times New Roman", size=10, bold=True, italic=True)
            cell.fill = PatternFill("solid", fgColor=fill_color)
            cell.border = border
            if col == 1:
                cell.alignment = Alignment(horizontal="right", vertical="center")
            elif col == 4:
                horizontal = "center" if kind == "header" else "left"
                wrap = True if kind == "lesson" else None
                cell.alignment = Alignment(horizontal=horizontal, vertical="top", wrap_text=wrap)
            else:
                cell.alignment = Alignment(horizontal="left", vertical="top")


def strip_hours(text: str) -> str:
    return clean_spaces(re.sub(r"\([^)]*годин[^)]*\)", "", text, flags=re.IGNORECASE))


def split_topic_lesson_rows(text: str) -> list[tuple[str, str, str]]:
    text = norm_text(text)
    if not text:
        return []
    marked = re.sub(r"(?<!^)\s*(?=(Тема|Заня\w*)\s*№?\s*\d+\b)", "\n", text, flags=re.IGNORECASE)
    rows: list[tuple[str, str, str]] = []
    for chunk in marked.splitlines():
        chunk = clean_spaces(chunk)
        if not chunk:
            continue
        topic = re.match(r"^(Тема\s*№?\s*\d+)\.?\s*(.*)$", chunk, re.IGNORECASE)
        if topic:
            rows.append((topic.group(1), strip_hours(topic.group(2)), "topic"))
            continue
        lesson = re.match(r"^(Заня\w*\s*№?\s*\d+)\.?\s*(.*)$", chunk, re.IGNORECASE)
        if lesson:
            rows.append((lesson.group(1), clean_spaces(lesson.group(2)), "lesson"))
    if rows:
        return rows
    return [("Заняття", clean_spaces(text), "lesson")]


def schedule_details_from_group(events: list[Event], grids) -> list[LessonDetails]:
    if not events:
        return []
    if events[0].subject_code == "ПсихП":
        detail = psych_lesson_from_group(events, grids)
        return [detail] if detail else []
    return [details for event in events if (details := lesson_from_event(event, grids)) is not None]


def safe_sheet_title(title: str, fallback: str, used: set[str]) -> str:
    cleaned = re.sub(r"[\[\]\:\*\?\/\\]", " ", clean_spaces(title or fallback)).strip() or fallback
    cleaned = cleaned[:31]
    candidate = cleaned
    index = 2
    while candidate in used:
        suffix = f" {index}"
        candidate = f"{cleaned[:31 - len(suffix)]}{suffix}"
        index += 1
    used.add(candidate)
    return candidate


def ordered_grid_details(grid: dict[tuple, LessonDetails]) -> list[tuple[tuple[int, str, str], LessonDetails]]:
    rows = [
        (key, detail)
        for key, detail in grid.items()
        if isinstance(key, tuple) and len(key) == 3 and isinstance(key[0], int) and isinstance(detail, LessonDetails)
    ]
    part_order = {"day": 0, "night": 1}
    rows.sort(key=lambda item: (item[0][0], part_order.get(item[0][1], 9), item[0][2] or ""))
    return rows


def schedule_topic_stop_row(ws, day_header_row: int) -> int:
    for row in range(day_header_row + 1, ws.max_row + 1):
        row_text = " ".join(norm_text(ws.cell(row, col).value).upper() for col in range(1, ws.max_column + 1))
        if re.search(r"НАВЧАЛЬН\w*\s+(МІСЦЯ|МЕСТА|МІСЦЕ|ТОЧК)", row_text):
            return row - 1
    return ws.max_row


def read_grid_schedule_blocks(path: Path) -> list[tuple[int, str, list[tuple[str, str, str]]]]:
    wb = load_workbook(path, data_only=False)
    blocks: dict[tuple[int, str], list[tuple[str, str, str]]] = {}
    order: list[tuple[int, str]] = []
    seen_by_block: dict[tuple[int, str], set[tuple[str, str]]] = {}

    for source_ws in wb.worksheets:
        all_day_cols = find_all_day_columns_in_grid(source_ws)
        if not all_day_cols:
            continue

        day_header_row: int | None = None
        for row in range(1, min(source_ws.max_row, 10) + 1):
            for col in range(1, source_ws.max_column + 1):
                if DAY_RE.search(norm_text(source_ws.cell(row, col).value)):
                    day_header_row = row
                    break
            if day_header_row:
                break
        if not day_header_row:
            continue

        col_to_day = {col: day_no for day_no, col in all_day_cols}
        kind_row = find_kind_label_row(source_ws, day_header_row, {col: col for col in col_to_day})
        col_kind: dict[int, str] = {}
        if kind_row:
            kind_pattern = re.compile(r"ТЕОРЕТ|ПРАКТ", re.IGNORECASE)
            for col in col_to_day:
                raw = norm_text(merged_parent(source_ws, kind_row, col).value)
                col_kind[col] = normalize_block_kind(raw) if kind_pattern.search(raw) else ""

        stop_row = schedule_topic_stop_row(source_ws, day_header_row)
        for day_no, col in all_day_cols:
            kind = col_kind.get(col, "")
            block_key = (day_no, kind)
            if block_key not in blocks:
                blocks[block_key] = []
                seen_by_block[block_key] = set()
                order.append(block_key)

            seen_cells: set[str] = set()
            for row in range(day_header_row + 1, stop_row + 1):
                parent = merged_parent(source_ws, row, col)
                if parent.coordinate in seen_cells:
                    continue
                seen_cells.add(parent.coordinate)
                text = clean_spaces(parent.value or "")
                if not text:
                    continue
                if not re.search(r"\b(Тема|Заня\w*)\s*№?\s*\d+", text, re.IGNORECASE):
                    continue
                for label, content, kind_name in split_topic_lesson_rows(text):
                    row_key = (label, content)
                    if row_key in seen_by_block[block_key]:
                        continue
                    seen_by_block[block_key].add(row_key)
                    blocks[block_key].append((label, content, kind_name))

    return [(key[0], key[1], blocks[key]) for key in sorted(order, key=lambda item: (item[0], item[1] or "")) if blocks[key]]


def write_lesson_schedule(
    output_path: Path,
    grid_paths: dict[str, str],
    subject_colors: dict[str, str] | None = None,
) -> None:
    subject_colors = {code: normalize_fill_color(color) for code, color in (subject_colors or {}).items() if color}
    wb = Workbook()
    used_titles: set[str] = set()
    created_sheets = 0

    for code, path_text in grid_paths.items():
        path = Path(path_text)
        if not code.strip() or not path.exists():
            continue
        schedule_blocks = read_grid_schedule_blocks(path)
        if not schedule_blocks:
            continue

        ws = wb.active if created_sheets == 0 else wb.create_sheet()
        created_sheets += 1
        subject = SUBJECT_NAMES.get(code, code)
        ws.title = safe_sheet_title(subject, code, used_titles)
        setup_lesson_schedule_sheet(ws)
        fill_color = subject_colors.get(code, SCHEDULE_FILL)

        row = 1
        for day_no, kind, schedule_rows in schedule_blocks:
            kind_label = f" ({kind.upper()})" if kind else ""
            header = f"{subject.upper()}. ДЕНЬ {day_no}{kind_label}"
            ws.merge_cells(start_row=row, start_column=4, end_row=row, end_column=18)
            ws.cell(row, 4).value = header
            apply_lesson_schedule_block(ws, row, row, "header", fill_color)
            row += 1

            for label, content, kind_name in schedule_rows:
                span = lesson_schedule_span(content, kind_name)
                end_row = row + span - 1
                ws.cell(row, 1).value = label
                if span > 1:
                    ws.merge_cells(start_row=row, start_column=1, end_row=end_row, end_column=1)
                ws.merge_cells(start_row=row, start_column=4, end_row=end_row, end_column=18)
                ws.cell(row, 4).value = content
                apply_lesson_schedule_block(ws, row, end_row, kind_name, fill_color)
                row = end_row + 1

        set_border(ws, 1, max(1, row - 1), 1, 18)

    if not created_sheets:
        raise ValueError("Не знайдено сіток для розкладу.")
    output_path.parent.mkdir(parents=True, exist_ok=True)
    wb.save(output_path)


def append_schedule_rows(ws, row: int, rows: list[tuple[str, str, str]], fill_color: str) -> int:
    for label, content, kind_name in rows:
        span = lesson_schedule_span(content, kind_name)
        end_row = row + span - 1
        ws.cell(row, 1).value = label
        if span > 1:
            ws.merge_cells(start_row=row, start_column=1, end_row=end_row, end_column=1)
        ws.merge_cells(start_row=row, start_column=4, end_row=end_row, end_column=18)
        ws.cell(row, 4).value = content
        apply_lesson_schedule_block(ws, row, end_row, kind_name, fill_color)
        row = end_row + 1
    return row


def add_schedule_day_gap(ws, row: int, text: str, fill_color: str) -> int:
    if text:
        ws.merge_cells(start_row=row, start_column=4, end_row=row, end_column=18)
        ws.cell(row, 4).value = text
        apply_lesson_schedule_block(ws, row, row, "header", fill_color)
        row += 1
    else:
        ws.row_dimensions[row].height = 14.1
        row += 1
    ws.row_dimensions[row].height = 14.1
    row += 1
    return row


def week_schedule_rows_from_group(events: list[Event], grids) -> list[tuple[str, str, str]]:
    rows: list[tuple[str, str, str]] = []
    for detail in schedule_details_from_group(events, grids):
        rows.extend(split_topic_lesson_rows(detail.title))
    return rows


def write_week_lesson_schedule(
    lg_path: Path,
    week_sheet: str,
    output_path: Path,
    grid_paths: dict[str, str],
    units_text: str,
    subject_colors: dict[str, str] | None = None,
    day_order_text: str = "",
) -> None:
    subject_colors = {code: normalize_fill_color(color) for code, color in (subject_colors or {}).items() if color}
    day_order = parse_day_order(day_order_text)
    grids = read_all_grids(grid_paths)
    lg_wb = load_workbook(lg_path, data_only=False)
    week_ws, date_row, day_cols = find_week_context(lg_wb, week_sheet)
    selected_units = [item.strip() for item in re.split(r"[,;]", units_text) if item.strip()]
    dates, units = read_schedule(week_ws, selected_units, set(grids.keys()), date_row, day_cols)
    psych_day_by_date_index, selected_local_to_absolute = psych_day_map_from_lg(week_ws, date_row, selected_units, set(grids.keys()), day_cols)
    apply_psych_days(units, psych_day_by_date_index, selected_local_to_absolute)
    dates = (dates + [None] * 7)[:7]
    if not units:
        raise ValueError("В выбранной неделе не найдены указанные подразделения.")

    payload: dict[str, dict[int, list[tuple[str, list[tuple[str, str, str]]]]]] = {}
    for _unit_full, _unit_short, events in units:
        for day_index in range(7):
            day_events = [event for event in events if event.day_index == day_index and event.subject_code]
            for group in detail_event_groups(day_events):
                subject_code = group[0].subject_code
                if not subject_code:
                    continue
                rows = week_schedule_rows_from_group(group, grids)
                if not rows:
                    continue
                kind = ""
                details = schedule_details_from_group(group, grids)
                if details:
                    kind = details[0].kind_label
                elif group[0].kind_label:
                    kind = group[0].kind_label
                payload.setdefault(subject_code, {}).setdefault(day_index, []).append((kind, rows))

    if not payload:
        raise ValueError("Не знайдено занять для розкладу на вибрану неділю.")

    wb = Workbook()
    used_titles: set[str] = set()
    created_sheets = 0
    for code in grid_paths:
        if code not in payload:
            continue
        ws = wb.active if created_sheets == 0 else wb.create_sheet()
        created_sheets += 1
        subject = SUBJECT_NAMES.get(code, code)
        ws.title = safe_sheet_title(subject, code, used_titles)
        setup_lesson_schedule_sheet(ws)
        fill_color = subject_colors.get(code, SCHEDULE_FILL)
        row = 1

        for display_day, source_day in enumerate(day_order):
            if source_day not in payload[code]:
                continue
            date_text = dates[source_day].strftime("%d.%m.%Y") if isinstance(dates[source_day], datetime) else ""
            day_name = day_name_for_date(dates, source_day).title()
            for kind, rows in payload[code][source_day]:
                kind_label = f"({kind.upper()})" if kind else ""
                header = " ".join(part for part in [subject.upper(), kind_label] if part)
                ws.merge_cells(start_row=row, start_column=4, end_row=row, end_column=18)
                ws.cell(row, 4).value = header
                apply_lesson_schedule_block(ws, row, row, "header", fill_color)
                row += 1
                row = append_schedule_rows(ws, row, rows, fill_color)
            row = add_schedule_day_gap(ws, row, " ".join(part for part in [day_name, date_text] if part), fill_color)

        set_border(ws, 1, max(1, row - 3), 1, 18)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    wb.save(output_path)


def setup_summary_plan_sheet(ws) -> None:
    ws.page_setup.orientation = "landscape"
    ws.page_setup.fitToWidth = 1
    ws.sheet_properties.pageSetUpPr.fitToPage = True
    for col in range(1, 9):
        ws.column_dimensions[get_column_letter(col)].width = 12.625 if col == 1 else 43.0


def append_summary_units(
    ws,
    start_row: int,
    dates: list[datetime | None],
    units: list[tuple[str, str, list[Event]]],
    day_order: list[int],
    subject_colors: dict[str, str],
    day_color: str,
) -> int:
    row = start_row
    for unit_full, unit_short, events in units:
        day_events = {i: [e for e in events if e.day_index == i and e.part == "day"] for i in range(7)}
        night_events = {i: [e for e in events if e.day_index == i and e.part == "night"] for i in range(7)}

        for display_day, source_day in enumerate(day_order):
            col = display_day + 2
            ws.cell(row, col).value = day_name_for_date(dates, source_day)
            ws.cell(row + 1, col).value = day_date_text(dates, source_day)
            apply_common_style(ws, row, col, "header")
            apply_common_style(ws, row + 1, col, "header")
        ws.merge_cells(start_row=row, start_column=1, end_row=row + 1, end_column=1)
        apply_common_style(ws, row, 1, "unit")
        ws.row_dimensions[row].height = 40
        ws.row_dimensions[row + 1].height = 35

        summary_row = row + 2
        ws.cell(summary_row, 1).value = unit_short
        summary_day_groups_by_display: list[list[list[Event]]] = []
        summary_night_groups_by_display: list[list[list[Event]]] = []
        for source_day in day_order:
            summary_day_groups_by_display.append(summary_event_groups(day_events[source_day]))
            summary_night_groups_by_display.append(summary_event_groups(night_events[source_day]))
        max_summary_day_rows = max([len(groups) for groups in summary_day_groups_by_display] + [1])
        max_summary_night_rows = max([len(groups) for groups in summary_night_groups_by_display] + [0])
        summary_total_rows = max_summary_day_rows + max_summary_night_rows
        ws.merge_cells(start_row=summary_row, start_column=1, end_row=summary_row + summary_total_rows - 1, end_column=1)
        apply_common_style(ws, summary_row, 1, "unit")

        for display_day in range(7):
            col = display_day + 2
            day_groups = summary_day_groups_by_display[display_day]
            night_groups = summary_night_groups_by_display[display_day]
            for offset in range(summary_total_rows):
                apply_common_style(ws, summary_row + offset, col, "summary")

            day_area_start = summary_row
            day_area_end = summary_row + (max_summary_day_rows - 1 if night_groups else summary_total_rows - 1)
            day_area_rows = day_area_end - day_area_start + 1
            if day_groups:
                cursor = day_area_start
                base_span = max(1, day_area_rows // len(day_groups))
                extra_rows = max(0, day_area_rows - base_span * len(day_groups))
                for index, group in enumerate(day_groups):
                    span = base_span + (1 if index < extra_rows else 0)
                    end_row = cursor + span - 1
                    ws.cell(cursor, col).value = format_summary_group(group)
                    fill_range(ws, cursor, end_row, col, col, summary_group_fill(group, subject_colors, day_color))
                    if end_row > cursor:
                        ws.merge_cells(start_row=cursor, start_column=col, end_row=end_row, end_column=col)
                    cursor = end_row + 1
            elif day_area_end > day_area_start:
                ws.merge_cells(start_row=day_area_start, start_column=col, end_row=day_area_end, end_column=col)

            if max_summary_night_rows and night_groups:
                night_area_start = summary_row + max_summary_day_rows
                night_area_end = night_area_start + max_summary_night_rows - 1
                if len(night_groups) == 1 and max_summary_night_rows > 1:
                    ws.cell(night_area_start, col).value = format_summary_group(night_groups[0])
                    night_base = summary_group_fill(night_groups[0], subject_colors, day_color)
                    fill_range(ws, night_area_start, night_area_end, col, col, darken_color(night_base))
                    ws.merge_cells(start_row=night_area_start, start_column=col, end_row=night_area_end, end_column=col)
                else:
                    for index, group in enumerate(night_groups):
                        cell_row = night_area_start + index
                        ws.cell(cell_row, col).value = format_summary_group(group)
                        night_base = summary_group_fill(group, subject_colors, day_color)
                        fill_range(ws, cell_row, cell_row, col, col, darken_color(night_base))
                    if len(night_groups) < max_summary_night_rows:
                        blank_start = night_area_start + len(night_groups)
                        if blank_start < night_area_end:
                            ws.merge_cells(start_row=blank_start, start_column=col, end_row=night_area_end, end_column=col)

        for offset in range(summary_total_rows):
            ws.row_dimensions[summary_row + offset].height = estimate_height(
                [ws.cell(summary_row + offset, c).value for c in range(2, 9)],
                43,
                40,
            )
        set_border(ws, row, summary_row + summary_total_rows - 1, 1, 8)
        row = summary_row + summary_total_rows + 2
    return row


def write_summary_plan(
    lg_path: Path,
    week_sheet: str,
    template_path: Path | None,
    output_path: Path,
    grid_paths: dict[str, str],
    units_text: str,
    day_color: str,
    night_color: str,
    subject_colors: dict[str, str] | None = None,
    day_order_text: str = "",
) -> None:
    subject_colors = {code: color.replace("#", "").upper() for code, color in (subject_colors or {}).items() if color}
    day_order = parse_day_order(day_order_text)
    grid_codes = {code for code in grid_paths if code.strip()}
    lg_wb = load_workbook(lg_path, data_only=False)
    week_ws, date_row, day_cols = find_week_context(lg_wb, week_sheet)
    selected_units = [item.strip() for item in re.split(r"[,;]", units_text) if item.strip()]
    dates, units = read_schedule(week_ws, selected_units, grid_codes, date_row, day_cols)
    psych_day_by_date_index, selected_local_to_absolute = psych_day_map_from_lg(week_ws, date_row, selected_units, grid_codes, day_cols)
    apply_psych_days(units, psych_day_by_date_index, selected_local_to_absolute)
    dates = (dates + [None] * 7)[:7]
    if not units:
        raise ValueError("В выбранной неделе не найдены указанные роты.")

    wb = prepare_sheet(template_path)
    ws = wb.active
    setup_summary_plan_sheet(ws)
    append_summary_units(ws, 2, dates, units, day_order, subject_colors, day_color)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    wb.save(output_path)


def write_summary_plan_all_lg(
    lg_path: Path,
    week_sheet: str,
    template_path: Path | None,
    output_path: Path,
    grid_paths: dict[str, str],
    units_text: str,
    day_color: str,
    night_color: str,
    subject_colors: dict[str, str] | None = None,
    day_order_text: str = "",
) -> None:
    subject_colors = {code: color.replace("#", "").upper() for code, color in (subject_colors or {}).items() if color}
    day_order = parse_day_order(day_order_text)
    grid_codes = {code for code in grid_paths if code.strip()}
    selected_units = [item.strip() for item in re.split(r"[,;]", units_text) if item.strip()]

    base_wb = load_workbook(lg_path, data_only=False)
    base_ws, base_date_row, base_day_cols = find_week_context(base_wb, week_sheet)
    target_dates = selected_week_dates(base_ws, base_date_row, base_day_cols)

    wb = prepare_sheet(template_path)
    ws = wb.active
    setup_summary_plan_sheet(ws)
    row = 2
    found_units = 0
    collected: list[tuple[tuple[int, str], list[datetime | None], tuple[str, str, list[Event]]]] = []

    lg_folder = lg_path.parent
    lg_files = sorted(
        path
        for path in lg_folder.glob("*.xlsx")
        if not path.name.startswith("~$")
    )
    for current_lg_path in lg_files:
        try:
            current_wb = load_workbook(current_lg_path, data_only=False)
            week_ws, date_row, day_cols = find_week_context_by_dates(current_wb, target_dates)
            dates, units = read_schedule(week_ws, selected_units, grid_codes, date_row, day_cols)
            psych_day_by_date_index, selected_local_to_absolute = psych_day_map_from_lg(
                week_ws,
                date_row,
                selected_units,
                grid_codes,
                day_cols,
            )
            apply_psych_days(units, psych_day_by_date_index, selected_local_to_absolute)
        except Exception:
            continue

        dates = (dates + [None] * 7)[:7]
        if not units:
            continue
        found_units += len(units)
        for unit in units:
            collected.append((unit_sort_key(unit[0], unit[1]), dates, unit))

    if not found_units:
        raise ValueError("У папці з ЛГ не знайдено рот для вибраного періоду дат.")

    for _sort_key, dates, unit in sorted(collected, key=lambda item: item[0]):
        row = append_summary_units(ws, row, dates, [unit], day_order, subject_colors, day_color)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    wb.save(output_path)


def write_plan(
    lg_path: Path,
    week_sheet: str,
    template_path: Path | None,
    output_path: Path,
    grid_paths: dict[str, str],
    units_text: str,
    day_color: str,
    night_color: str,
    subject_colors: dict[str, str] | None = None,
    day_order_text: str = "",
) -> None:
    subject_colors = {code: color.replace("#", "").upper() for code, color in (subject_colors or {}).items() if color}
    day_order = parse_day_order(day_order_text)
    grids = read_all_grids(grid_paths)
    lg_wb = load_workbook(lg_path, data_only=False)
    week_ws, date_row, day_cols = find_week_context(lg_wb, week_sheet)
    selected_units = [item.strip() for item in re.split(r"[,;]", units_text) if item.strip()]
    dates, units = read_schedule(week_ws, selected_units, set(grids.keys()), date_row, day_cols)
    psych_day_by_date_index, selected_local_to_absolute = psych_day_map_from_lg(week_ws, date_row, selected_units, set(grids.keys()), day_cols)
    apply_psych_days(units, psych_day_by_date_index, selected_local_to_absolute)
    dates = (dates + [None] * 7)[:7]
    if not units:
        raise ValueError("В выбранной неделе не найдены указанные подразделения.")

    wb = prepare_sheet(template_path)
    ws = wb.active
    ws.page_setup.orientation = "landscape"
    ws.page_setup.fitToWidth = 1
    ws.sheet_properties.pageSetUpPr.fitToPage = True

    for col in range(1, 9):
        ws.column_dimensions[get_column_letter(col)].width = 12.625 if col == 1 else 43.0

    row = 2
    for unit_full, unit_short, events in units:
        day_events = {i: [e for e in events if e.day_index == i and e.part == "day"] for i in range(7)}
        night_events = {i: [e for e in events if e.day_index == i and e.part == "night"] for i in range(7)}

        for display_day, source_day in enumerate(day_order):
            col = display_day + 2
            ws.cell(row, col).value = day_name_for_date(dates, source_day)
            ws.cell(row + 1, col).value = day_date_text(dates, source_day)
            apply_common_style(ws, row, col, "header")
            apply_common_style(ws, row + 1, col, "header")
        ws.merge_cells(start_row=row, start_column=1, end_row=row + 1, end_column=1)
        apply_common_style(ws, row, 1, "unit")
        ws.row_dimensions[row].height = 40
        ws.row_dimensions[row + 1].height = 35

        summary_row = row + 2
        ws.cell(summary_row, 1).value = unit_short
        summary_day_groups_by_display: list[list[list[Event]]] = []
        summary_night_groups_by_display: list[list[list[Event]]] = []
        for display_day in range(7):
            source_day = day_order[display_day]
            summary_day_groups_by_display.append(summary_event_groups(day_events[source_day]))
            summary_night_groups_by_display.append(summary_event_groups(night_events[source_day]))
        max_summary_day_rows = max([len(groups) for groups in summary_day_groups_by_display] + [1])
        max_summary_night_rows = max([len(groups) for groups in summary_night_groups_by_display] + [0])
        summary_total_rows = max_summary_day_rows + max_summary_night_rows
        ws.merge_cells(start_row=summary_row, start_column=1, end_row=summary_row + summary_total_rows - 1, end_column=1)
        apply_common_style(ws, summary_row, 1, "unit")
        for display_day in range(7):
            col = display_day + 2
            day_groups = summary_day_groups_by_display[display_day]
            night_groups = summary_night_groups_by_display[display_day]
            for offset in range(summary_total_rows):
                apply_common_style(ws, summary_row + offset, col, "summary")

            day_area_start = summary_row
            day_area_end = summary_row + (max_summary_day_rows - 1 if night_groups else summary_total_rows - 1)
            day_area_rows = day_area_end - day_area_start + 1
            if day_groups:
                cursor = day_area_start
                base_span = max(1, day_area_rows // len(day_groups))
                extra_rows = max(0, day_area_rows - base_span * len(day_groups))
                for index, group in enumerate(day_groups):
                    span = base_span + (1 if index < extra_rows else 0)
                    end_row = cursor + span - 1
                    ws.cell(cursor, col).value = format_summary_group(group)
                    fill_range(ws, cursor, end_row, col, col, summary_group_fill(group, subject_colors, day_color))
                    if end_row > cursor:
                        ws.merge_cells(start_row=cursor, start_column=col, end_row=end_row, end_column=col)
                    cursor = end_row + 1
            else:
                if day_area_end > day_area_start:
                    ws.merge_cells(start_row=day_area_start, start_column=col, end_row=day_area_end, end_column=col)

            if max_summary_night_rows and night_groups:
                night_area_start = summary_row + max_summary_day_rows
                night_area_end = night_area_start + max_summary_night_rows - 1
                if len(night_groups) == 1 and max_summary_night_rows > 1:
                    ws.cell(night_area_start, col).value = format_summary_group(night_groups[0])
                    night_base = summary_group_fill(night_groups[0], subject_colors, day_color)
                    fill_range(ws, night_area_start, night_area_end, col, col, darken_color(night_base))
                    ws.merge_cells(start_row=night_area_start, start_column=col, end_row=night_area_end, end_column=col)
                else:
                    for index, group in enumerate(night_groups):
                        cell_row = night_area_start + index
                        ws.cell(cell_row, col).value = format_summary_group(group)
                        night_base = summary_group_fill(group, subject_colors, day_color)
                        fill_range(ws, cell_row, cell_row, col, col, darken_color(night_base))
                    if len(night_groups) < max_summary_night_rows:
                        blank_start = night_area_start + len(night_groups)
                        if blank_start < night_area_end:
                            ws.merge_cells(start_row=blank_start, start_column=col, end_row=night_area_end, end_column=col)

        for offset in range(summary_total_rows):
            ws.row_dimensions[summary_row + offset].height = estimate_height(
                [ws.cell(summary_row + offset, c).value for c in range(2, 9)],
                43,
                40,
            )
        set_border(ws, row, summary_row + summary_total_rows - 1, 1, 8)

        detail_header = summary_row + summary_total_rows + 2
        for display_day, source_day in enumerate(day_order):
            col = display_day + 2
            ws.cell(detail_header, col).value = day_name_for_date(dates, source_day)
            ws.cell(detail_header + 1, col).value = day_date_text(dates, source_day)
            apply_common_style(ws, detail_header, col, "header_small")
            apply_common_style(ws, detail_header + 1, col, "header_small")
        ws.merge_cells(start_row=detail_header, start_column=1, end_row=detail_header + 1, end_column=1)
        ws.row_dimensions[detail_header].height = 22
        ws.row_dimensions[detail_header + 1].height = 18
        ws.cell(detail_header + 2, 1).value = unit_full
        apply_common_style(ws, detail_header + 2, 1, "unit")

        columns_parts: list[tuple[list[DetailBlock], list[DetailBlock]]] = []
        for source_day in day_order:
            day_headers: list[DetailBlock] = []
            day_details: list[DetailBlock] = []

            seen_day_titles: set[str] = set()
            for event_group in detail_event_groups(day_events[source_day]):
                if event_group[0].subject_code == "ПсихП":
                    psych_detail = psych_lesson_from_group(event_group, grids)
                    group_details = [psych_detail] if psych_detail else []
                else:
                    group_details = [details for event in event_group if (details := lesson_from_event(event, grids)) is not None]
                if not group_details:
                    continue
                title = format_combined_title(event_group, group_details)
                if title in seen_day_titles:
                    continue
                seen_day_titles.add(title)
                fill = subject_colors.get(event_group[0].subject_code or "", day_color)
                day_headers.append(DetailBlock(title, "detail_title", fill))
                day_headers.append(DetailBlock("Місце проведення:", "place", fill))
                items_seen: set[str] = set()
                for detail in group_details:
                    for item in detail.items or []:
                        for point in split_training_points(item):
                            if point not in items_seen:
                                items_seen.add(point)
                                day_details.append(DetailBlock(point, "body", None, detail_span(point)))

            night_headers: list[DetailBlock] = []
            night_details: list[DetailBlock] = []
            seen_night_titles: set[str] = set()
            for event_group in detail_event_groups(night_events[source_day]):
                if event_group[0].subject_code == "ПсихП":
                    psych_detail = psych_lesson_from_group(event_group, grids)
                    group_details = [psych_detail] if psych_detail else []
                else:
                    group_details = [details for event in event_group if (details := lesson_from_event(event, grids)) is not None]
                if not group_details:
                    continue
                title = format_combined_title(event_group, group_details)
                if title in seen_night_titles:
                    continue
                seen_night_titles.add(title)
                day_like_fill = subject_colors.get(event_group[0].subject_code or "", day_color)
                fill = darken_color(day_like_fill)
                night_headers.append(DetailBlock(title, "detail_title", fill))
                night_headers.append(DetailBlock("Місце проведення:", "place", fill))
                items_seen_night: set[str] = set()
                for detail in group_details:
                    for item in detail.items or []:
                        for point in split_training_points(item):
                            if point not in items_seen_night:
                                items_seen_night.add(point)
                                night_details.append(DetailBlock(point, "body", DETAIL_FILL, detail_span(point)))

            headers: list[DetailBlock] = []
            details: list[DetailBlock] = []
            headers.extend(day_headers)
            headers.extend(night_headers)
            details.extend(day_details)
            details.extend(night_details)
            columns_parts.append((headers, details))

        max_header_rows = max([sum(block.span for block in headers) for headers, _details in columns_parts] + [0])
        columns_payload: list[list[DetailBlock]] = []
        for headers, details in columns_parts:
            payload: list[DetailBlock] = []
            payload.extend(headers)
            header_rows = sum(block.span for block in headers)
            payload.extend(DetailBlock("", "detail_title") for _ in range(max_header_rows - header_rows))
            payload.extend(details)
            columns_payload.append(payload)

        max_rows = max([sum(block.span for block in payload) for payload in columns_payload] + [1])
        detail_start = detail_header + 2
        row_height_needs = {detail_start + offset: 18.0 for offset in range(max_rows)}
        for offset in range(max_rows):
            current_row = detail_start + offset
            ws.cell(current_row, 1).value = unit_full if offset == 0 else ""
            apply_common_style(ws, current_row, 1, "unit")

        for day, payload in enumerate(columns_payload):
            col = day + 2
            cursor = detail_start
            for block in payload:
                cell = ws.cell(cursor, col)
                cell.value = block.text
                apply_common_style(ws, cursor, col, block.kind)
                if block.fill:
                    fill_range(ws, cursor, cursor + block.span - 1, col, col, block.fill)
                if block.kind == "detail_title":
                    font_size = 16.0
                    needed = estimate_height([block.text], 43, font_size * 1.35, font_size)
                elif block.kind == "place":
                    font_size = 16.0
                    needed = font_size * 1.6
                else:
                    font_size = 10.0
                    needed = estimate_height([block.text], 43, font_size * 1.5, font_size)
                # Distribute height evenly across span rows, but each row gets at least needed/span
                per_row = max(font_size * 1.35, needed / max(1, block.span))
                for row_index in range(cursor, cursor + block.span):
                    row_height_needs[row_index] = max(row_height_needs.get(row_index, 30.0), per_row)
                if block.span > 1:
                    ws.merge_cells(start_row=cursor, start_column=col, end_row=cursor + block.span - 1, end_column=col)
                cursor += block.span
            for blank_row in range(cursor, detail_start + max_rows):
                apply_common_style(ws, blank_row, col, "body")

        for row_index, height in row_height_needs.items():
            ws.row_dimensions[row_index].height = min(409, height)

        if max_rows > 1:
            ws.merge_cells(start_row=detail_start, start_column=1, end_row=detail_start + max_rows - 1, end_column=1)
        set_border(ws, detail_header, detail_start + max_rows - 1, 1, 8)
        row = detail_start + max_rows + 2

    output_path.parent.mkdir(parents=True, exist_ok=True)
    wb.save(output_path)


class PlanApp:
    def __init__(self, root: Tk):
        self.root = root
        self.root.title("Генератор плану занять")
        self.config = self.load_config()

        self.lg_path = StringVar(value=self.config.get("lg_path", ""))
        self.template_path = StringVar(value=self.config.get("template_path", ""))
        self.output_path = StringVar(value=self.config.get("output_path", str(APP_DIR / "ПЛАН_готовий.xlsx")))
        self.week_sheet = StringVar(value=self.config.get("week_sheet", ""))
        self.week_range = StringVar(value="")
        self.day_order = StringVar(value=self.config.get("day_order", "1,2,3,4,5,6,7"))
        self.units = StringVar(value=self.config.get("units", "2НР"))
        self.day_color = StringVar(value=self.config.get("day_color", "FFFFC000"))
        self.night_color = StringVar(value=self.config.get("night_color", "FFFF0000"))
        self.schedule_mode = StringVar(value=self.config.get("schedule_mode", "Всі сітки"))
        self.grid_vars: list[tuple[StringVar, StringVar, StringVar]] = []

        self.build()
        if self.lg_path.get():
            self.refresh_weeks()

    def load_config(self) -> dict:
        if CONFIG_PATH.exists():
            try:
                return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
            except Exception:
                return {}
        return {}

    def save_config(self) -> None:
        grids = [{"code": code.get(), "path": path.get(), "color": color.get()} for code, path, color in self.grid_vars]
        CONFIG_PATH.write_text(
            json.dumps(
                {
                    "lg_path": self.lg_path.get(),
                    "template_path": self.template_path.get(),
                    "output_path": self.output_path.get(),
                    "week_sheet": self.week_sheet.get(),
                    "day_order": self.day_order.get(),
                    "units": self.units.get(),
                    "day_color": self.day_color.get(),
                    "night_color": self.night_color.get(),
                    "schedule_mode": self.schedule_mode.get(),
                    "grids": grids,
                },
                ensure_ascii=False,
                indent=2,
            ),
            encoding="utf-8",
        )

    def build(self) -> None:
        frame = ttk.Frame(self.root, padding=12)
        frame.grid(sticky="nsew")
        self.root.columnconfigure(0, weight=1)
        frame.columnconfigure(1, weight=1)

        self.file_row(frame, 0, "Линейный график", self.lg_path, self.choose_lg)
        ttk.Label(frame, text="Неделя").grid(row=1, column=0, sticky="w", pady=4)
        self.week_combo = ttk.Combobox(frame, textvariable=self.week_sheet, state="readonly")
        self.week_combo.grid(row=1, column=1, sticky="ew", pady=4)
        self.week_combo.bind("<<ComboboxSelected>>", lambda _event: self.update_week_selection())
        ttk.Button(frame, text="Обновить", command=self.refresh_weeks).grid(row=1, column=2, padx=4)

        ttk.Label(frame, text="Диапазон").grid(row=2, column=0, sticky="w", pady=4)
        ttk.Label(frame, textvariable=self.week_range).grid(row=2, column=1, sticky="w", pady=4)

        ttk.Label(frame, text="Порядок дней").grid(row=3, column=0, sticky="w", pady=4)
        ttk.Entry(frame, textvariable=self.day_order).grid(row=3, column=1, sticky="ew", pady=4)
        ttk.Label(frame, text="1-7, например 1,2,4,3,5,6,7").grid(row=3, column=2, sticky="w")

        self.file_row(frame, 4, "Пример/шаблон", self.template_path, lambda: self.choose_file(self.template_path))
        self.file_row(frame, 5, "Итоговый файл", self.output_path, self.choose_output)

        ttk.Label(frame, text="Роти").grid(row=6, column=0, sticky="w", pady=4)
        self.units_combo = ttk.Combobox(frame, textvariable=self.units)
        self.units_combo.grid(row=6, column=1, sticky="ew", pady=4)
        ttk.Label(frame, text="можна через кому: 1НР, 2НР").grid(row=6, column=2, sticky="w")
        ttk.Button(frame, text="Всі", command=lambda: self.units.set(ALL_UNITS_LABEL)).grid(row=6, column=3, padx=4)

        ttk.Label(frame, text="Цвет дневных").grid(row=7, column=0, sticky="w", pady=4)
        ttk.Entry(frame, textvariable=self.day_color, width=12).grid(row=7, column=1, sticky="w", pady=4)
        ttk.Button(frame, text="Выбрать", command=lambda: self.choose_color(self.day_color)).grid(row=7, column=2, padx=4)
        ttk.Button(frame, text="Піпетка", command=lambda: self.pick_color(self.day_color)).grid(row=7, column=3, padx=4)

        ttk.Label(frame, text="Цвет ночных").grid(row=8, column=0, sticky="w", pady=4)
        ttk.Entry(frame, textvariable=self.night_color, width=12).grid(row=8, column=1, sticky="w", pady=4)
        ttk.Button(frame, text="Выбрать", command=lambda: self.choose_color(self.night_color)).grid(row=8, column=2, padx=4)
        ttk.Button(frame, text="Піпетка", command=lambda: self.pick_color(self.night_color)).grid(row=8, column=3, padx=4)

        ttk.Label(frame, text="Режим розкладу").grid(row=9, column=0, sticky="w", pady=(12, 4))
        schedule_mode_combo = ttk.Combobox(frame, textvariable=self.schedule_mode, state="readonly", values=["Всі сітки", "Тиждень з ЛГ"])
        schedule_mode_combo.grid(row=9, column=1, sticky="w", pady=(12, 4))

        ttk.Label(frame, text="Сетки и цвета предметов").grid(row=10, column=0, sticky="w", pady=(12, 4))
        grid_config = self.config.get("grids") or []
        for index, (code, name) in enumerate(SUBJECT_DEFAULTS):
            saved = next((item for item in grid_config if item.get("code") == code), {})
            code_var = StringVar(value=saved.get("code", code))
            path_var = StringVar(value=saved.get("path") or find_default_grid_path(code))
            color_var = StringVar(value=saved.get("color", ""))
            self.grid_vars.append((code_var, path_var, color_var))
            row = 11 + index
            ttk.Entry(frame, textvariable=code_var, width=8).grid(row=row, column=0, sticky="w", pady=2)
            ttk.Entry(frame, textvariable=path_var).grid(row=row, column=1, sticky="ew", pady=2)
            ttk.Button(frame, text="...", command=lambda v=path_var: self.choose_file(v)).grid(row=row, column=2, padx=4)
            ttk.Entry(frame, textvariable=color_var, width=10).grid(row=row, column=3, sticky="w", pady=2)
            ttk.Button(frame, text="Цвет", command=lambda v=color_var: self.choose_color(v)).grid(row=row, column=4, padx=4)
            ttk.Button(frame, text="Піпетка", command=lambda v=color_var: self.pick_color(v)).grid(row=row, column=5, padx=4)
            ttk.Label(frame, text=name).grid(row=row, column=6, sticky="w")

        action_row = 11 + len(SUBJECT_DEFAULTS)
        ttk.Button(frame, text="Розклад", command=self.generate_schedule).grid(row=action_row, column=0, sticky="w", pady=(14, 0))
        ttk.Button(frame, text="План по ротах", command=self.generate_summary).grid(row=action_row, column=1, sticky="w", pady=(14, 0))
        ttk.Button(frame, text="Создать план", command=self.generate).grid(row=action_row, column=2, sticky="e", pady=(14, 0))

    def file_row(self, frame, row: int, label: str, var: StringVar, command) -> None:
        ttk.Label(frame, text=label).grid(row=row, column=0, sticky="w", pady=4)
        ttk.Entry(frame, textvariable=var).grid(row=row, column=1, sticky="ew", pady=4)
        ttk.Button(frame, text="...", command=command).grid(row=row, column=2, padx=4)

    def choose_file(self, var: StringVar) -> None:
        path = filedialog.askopenfilename(filetypes=[("Excel files", "*.xlsx")])
        if path:
            var.set(path)

    def choose_lg(self) -> None:
        self.choose_file(self.lg_path)
        self.refresh_weeks()

    def choose_output(self) -> None:
        path = filedialog.asksaveasfilename(defaultextension=".xlsx", filetypes=[("Excel files", "*.xlsx")])
        if path:
            self.output_path.set(path)

    def choose_color(self, var: StringVar) -> None:
        color = colorchooser.askcolor()[1]
        if color:
            var.set(color.replace("#", "").upper())

    def pick_color(self, var: StringVar) -> None:
        popup = Toplevel(self.root)
        popup.title("Піпетка")
        popup.resizable(False, False)
        popup.attributes("-topmost", True)
        popup.geometry("+120+120")
        label = ttk.Label(popup, text="Наведіть курсор на потрібний колір.\nЗчитування через 3 секунди...", padding=12)
        label.grid(row=0, column=0, sticky="nsew")
        ttk.Button(popup, text="Скасувати", command=popup.destroy).grid(row=1, column=0, pady=(0, 10))

        countdown = {"value": 3}

        def tick() -> None:
            if not popup.winfo_exists():
                return
            countdown["value"] -= 1
            if countdown["value"] <= 0:
                try:
                    var.set(screen_color_under_cursor())
                except Exception as exc:
                    messagebox.showerror("Піпетка", str(exc))
                finally:
                    if popup.winfo_exists():
                        popup.destroy()
                return
            label.configure(text=f"Наведіть курсор на потрібний колір.\nЗчитування через {countdown['value']} секунди...")
            popup.after(1000, tick)

        popup.after(1000, tick)

    def refresh_weeks(self) -> None:
        path = Path(self.lg_path.get())
        if not path.exists():
            return
        try:
            wb = load_workbook(path, read_only=True, data_only=True)
            weeks = []
            for ws in wb.worksheets:
                date_row = find_date_row(ws)
                if not date_row:
                    continue
                dates = [cell.value for cell in ws[date_row] if isinstance(cell.value, datetime)]
                if len(dates) <= 7:
                    weeks.append(ws.title)
                else:
                    for start in range(0, len(dates), 7):
                        if len(dates[start : start + 7]) >= 6:
                            weeks.append(week_option(ws, start, dates))
            self.week_combo["values"] = weeks
            if weeks and self.week_sheet.get() not in weeks:
                self.week_sheet.set(weeks[-1])
            self.update_week_selection()
        except Exception as exc:
            messagebox.showerror("Ошибка", str(exc))

    def update_week_selection(self) -> None:
        self.update_week_range()
        self.refresh_units()

    def update_week_range(self) -> None:
        path = Path(self.lg_path.get())
        if not path.exists() or not self.week_sheet.get():
            self.week_range.set("")
            return
        try:
            wb = load_workbook(path, read_only=True, data_only=True)
            ws, date_row, day_cols = find_week_context(wb, self.week_sheet.get())
            dates = [ws.cell(date_row, col).value for col in day_cols if isinstance(ws.cell(date_row, col).value, datetime)]
            if dates:
                self.week_range.set(f"{dates[0].strftime('%d.%m.%Y')} - {dates[-1].strftime('%d.%m.%Y')}")
            else:
                self.week_range.set("")
        except Exception:
            self.week_range.set("")

    def refresh_units(self) -> None:
        if not hasattr(self, "units_combo"):
            return
        try:
            choices = unit_choices_for_week(Path(self.lg_path.get()), self.week_sheet.get())
            self.units_combo["values"] = choices
            if choices and not self.units.get().strip():
                self.units.set(choices[0])
        except Exception:
            self.units_combo["values"] = []

    def generate(self) -> None:
        try:
            grid_paths = {code.get().strip(): path.get().strip() for code, path, _ in self.grid_vars if code.get().strip() and path.get().strip()}
            subject_colors = {code.get().strip(): color.get().strip() for code, _, color in self.grid_vars if code.get().strip() and color.get().strip()}
            write_plan(
                Path(self.lg_path.get()),
                self.week_sheet.get(),
                Path(self.template_path.get()) if self.template_path.get() else None,
                Path(self.output_path.get()),
                grid_paths,
                self.units.get(),
                self.day_color.get(),
                self.night_color.get(),
                subject_colors,
                self.day_order.get(),
            )
            self.save_config()
            messagebox.showinfo("Готово", f"План создан:\n{self.output_path.get()}")
        except Exception as exc:
            messagebox.showerror("Ошибка", str(exc))

    def schedule_output_path(self) -> Path:
        output = Path(self.output_path.get()) if self.output_path.get() else APP_DIR / "ПЛАН_готовий.xlsx"
        return output.with_name(f"{output.stem}_розклад{output.suffix or '.xlsx'}")

    def summary_output_path(self) -> Path:
        output = Path(self.output_path.get()) if self.output_path.get() else APP_DIR / "ПЛАН_готовий.xlsx"
        return output.with_name(f"{output.stem}_по_ротах{output.suffix or '.xlsx'}")

    def generate_summary(self) -> None:
        try:
            grid_paths = {code.get().strip(): path.get().strip() for code, path, _ in self.grid_vars if code.get().strip() and path.get().strip()}
            subject_colors = {code.get().strip(): color.get().strip() for code, _, color in self.grid_vars if code.get().strip() and color.get().strip()}
            output_path = self.summary_output_path()
            writer = write_summary_plan_all_lg if not normalize_selected_units([self.units.get()]) else write_summary_plan
            writer(
                Path(self.lg_path.get()),
                self.week_sheet.get(),
                Path(self.template_path.get()) if self.template_path.get() else None,
                output_path,
                grid_paths,
                self.units.get(),
                self.day_color.get(),
                self.night_color.get(),
                subject_colors,
                self.day_order.get(),
            )
            self.save_config()
            messagebox.showinfo("Готово", f"План по ротах створено:\n{output_path}")
        except Exception as exc:
            messagebox.showerror("Ошибка", str(exc))

    def generate_schedule(self) -> None:
        try:
            grid_paths = {code.get().strip(): path.get().strip() for code, path, _ in self.grid_vars if code.get().strip() and path.get().strip()}
            subject_colors = {code.get().strip(): color.get().strip() for code, _, color in self.grid_vars if code.get().strip() and color.get().strip()}
            output_path = self.schedule_output_path()
            if self.schedule_mode.get() == "Тиждень з ЛГ":
                write_week_lesson_schedule(
                    Path(self.lg_path.get()),
                    self.week_sheet.get(),
                    output_path,
                    grid_paths,
                    self.units.get(),
                    subject_colors,
                    self.day_order.get(),
                )
            else:
                write_lesson_schedule(
                    output_path,
                    grid_paths,
                    subject_colors,
                )
            self.save_config()
            messagebox.showinfo("Готово", f"Розклад створено:\n{output_path}")
        except Exception as exc:
            messagebox.showerror("Ошибка", str(exc))


def main() -> None:
    root = Tk()
    PlanApp(root)
    root.mainloop()


if __name__ == "__main__":
    main()
