from __future__ import annotations

import json
import math
import re
from copy import copy
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from tkinter import Tk, StringVar, 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"


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


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

SUBJECT_NAMES = dict(SUBJECT_DEFAULTS)
DETAIL_FILL = "FFD9D9D9"


SUBJECT_ALIASES = {
    "ВП": [r"\bВП\b", r"ВОГНЕВ"],
    "ТП": [r"\bТП\b", r"ТАКТИЧ"],
    "Інж": [r"\bІНЖ\b", r"ІНЖЕНЕР"],
    "Втоп": [r"\bВТОП\b", r"ТОПОГРАФ"],
    "ПсихП": [r"ПСИХ"],
    "РЕБ": [r"\bРЕБ\b"],
}


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


@dataclass
class LessonDetails:
    title: str = ""
    place: str = ""
    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 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()
    for code, patterns in SUBJECT_ALIASES.items():
        if code not in configured_codes:
            continue
        if any(re.search(pattern, upper, re.IGNORECASE) for pattern in patterns):
            return code
    for code in configured_codes:
        if code and code.upper() in upper:
            return 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 subject_match_positions(text: str, configured_codes: set[str]) -> list[tuple[int, str]]:
    matches: list[tuple[int, str]] = []
    for code, patterns in SUBJECT_ALIASES.items():
        if code not in configured_codes:
            continue
        for pattern in patterns:
            match = re.search(pattern, text, re.IGNORECASE)
            if match:
                matches.append((match.start(), code))
                break
    for code in configured_codes:
        if code not in SUBJECT_ALIASES and code:
            match = re.search(re.escape(code), text, re.IGNORECASE)
            if match:
                matches.append((match.start(), 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 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_matches(unit_name: str, short_name: str, selected_normalized: list[str], selected_numbers: list[str]) -> bool:
    if not selected_normalized and not selected_numbers:
        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
    unit_numbers = re.findall(r"\d+", unit_name)
    return bool(selected_numbers and any(number in unit_numbers for number in selected_numbers))


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)

    units = []
    selected_normalized = [u.upper().replace(" ", "") for u in selected_units if u.strip()]
    selected_numbers = [number for item in selected_units for number in re.findall(r"\d+", item)]
    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_numbers):
            continue

        events: list[Event] = []
        seen = set()
        for row in range(start, end + 1):
            num = ws.cell(row, 2).value
            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):
                    subject = guess_subject(event_text, grid_codes)
                    events.append(
                        Event(
                            day_index=day_index,
                            part=part,
                            text=event_text,
                            subject_code=subject,
                            day_no=parse_day_no(event_text),
                        )
                    )
        units.append((unit_name, short_name, events))
    return dates, units


def find_day_columns_in_grid(ws) -> dict[int, int]:
    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:
                result[int(match.group(1))] = 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
    return day_row, night_row


def read_grid(path: Path) -> dict[tuple[int, str], LessonDetails]:
    wb = load_workbook(path, data_only=False)
    details: dict[tuple[int, str], LessonDetails] = {}
    for ws in wb.worksheets:
        day_cols = find_day_columns_in_grid(ws)
        if not day_cols:
            continue
        day_section, night_section = section_rows(ws)
        for day_no, col in day_cols.items():
            title = clean_spaces(cell_value(ws, 1, 1) or "")
            topic = clean_spaces(cell_value(ws, 7, col) or cell_value(ws, 6, col) or "")
            if topic:
                title = topic

            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)
                    current = details.get(key)
                    candidate = LessonDetails(title=title, items=items)
                    if current is None or len(candidate.items or []) > len(current.items or []):
                        details[key] = candidate
    return details


def read_all_grids(grid_paths: dict[str, str]) -> dict[str, dict[tuple[int, str], 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 lesson_from_event(event: Event, grids) -> LessonDetails | None:
    if event.subject_code and event.day_no and event.subject_code in grids:
        details = grids[event.subject_code].get((event.day_no, event.part))
        if details:
            return details
    return None


def format_title(event: Event, details: LessonDetails) -> str:
    if event.subject_code and event.day_no:
        subject = SUBJECT_NAMES.get(event.subject_code, event.subject_code)
        part = "Вночі" if event.part == "night" else "Вдень"
        return f"Заняття:\n{subject} День {event.day_no} {part}"
    return f"Заняття:\n{event.text}"


def format_summary_event(event: Event) -> str:
    if event.subject_code and event.day_no:
        lines = [f"{event.subject_code} День {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 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 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) -> float:
    total_lines = 1
    chars_per_line = max(12, int(width * 0.95))
    for text in texts:
        if not text:
            continue
        for line in str(text).splitlines() or [""]:
            total_lines += max(1, math.ceil(len(line) / chars_per_line))
    return min(409, max(min_height, total_lines * 15.5))


def detail_span(text: str) -> int:
    if not text:
        return 1
    estimated = estimate_height([text], 43, 55)
    if estimated > 300:
        return 4
    if estimated > 200:
        return 3
    if estimated > 105:
        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 == "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 in {"detail_title", "place"}:
        cell.font = Font(name="Times New Roman", size=20)
        cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
    else:
        cell.font = Font(name="Times New Roman", size=11)
        cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)


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)
    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 in range(7):
            col = display_day + 2
            ws.cell(row, col).value = DAYS_UA[display_day]
            ws.cell(row + 1, col).value = dates[display_day].strftime("%d.%m") if dates[display_day] else ""
            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
        ws.merge_cells(start_row=summary_row, start_column=1, end_row=summary_row + 1, end_column=1)
        apply_common_style(ws, summary_row, 1, "unit")
        for display_day in range(7):
            source_day = day_order[display_day]
            col = display_day + 2
            summary_day_events = visible_summary_events(day_events[source_day])
            summary_night_events = visible_summary_events(night_events[source_day])
            day_text = "\n\n".join(format_summary_event(e) for e in summary_day_events)
            night_text = "\n\n".join(format_summary_event(e) for e in summary_night_events)
            ws.cell(summary_row, col).value = day_text
            ws.cell(summary_row + 1, col).value = night_text
            apply_common_style(ws, summary_row, col, "summary")
            apply_common_style(ws, summary_row + 1, col, "summary")
            day_fill = subject_colors.get(summary_day_events[0].subject_code or "", day_color) if summary_day_events else day_color
            night_base = subject_colors.get(summary_night_events[0].subject_code or "", day_color) if summary_night_events else day_color
            night_fill = darken_color(night_base)
            if day_text and not night_text:
                ws.merge_cells(start_row=summary_row, start_column=col, end_row=summary_row + 1, end_column=col)
                fill_range(ws, summary_row, summary_row + 1, col, col, day_fill)
            elif night_text and not day_text:
                ws.merge_cells(start_row=summary_row, start_column=col, end_row=summary_row + 1, end_column=col)
                fill_range(ws, summary_row, summary_row + 1, col, col, night_fill)
            else:
                if day_text:
                    fill_range(ws, summary_row, summary_row, col, col, day_fill)
                if night_text:
                    fill_range(ws, summary_row + 1, summary_row + 1, col, col, night_fill)
        ws.row_dimensions[summary_row].height = estimate_height([ws.cell(summary_row, c).value for c in range(2, 9)], 43, 80)
        ws.row_dimensions[summary_row + 1].height = estimate_height([ws.cell(summary_row + 1, c).value for c in range(2, 9)], 43, 80)
        set_border(ws, row, summary_row + 1, 1, 8)

        detail_header = summary_row + 3
        for display_day in range(7):
            col = display_day + 2
            ws.cell(detail_header, col).value = DAYS_UA[display_day]
            ws.cell(detail_header + 1, col).value = dates[display_day].strftime("%d.%m") if dates[display_day] else ""
            apply_common_style(ws, detail_header, col, "header")
            apply_common_style(ws, detail_header + 1, col, "header")
        ws.merge_cells(start_row=detail_header, start_column=1, end_row=detail_header + 1, end_column=1)
        ws.cell(detail_header + 2, 1).value = unit_full
        apply_common_style(ws, detail_header + 2, 1, "unit")

        columns_payload: list[list[DetailBlock]] = []
        for source_day in day_order:
            day_blocks: list[DetailBlock] = []
            night_blocks: list[DetailBlock] = []
            day_detail_items: list[str] = []

            for event in day_events[source_day]:
                details = lesson_from_event(event, grids)
                if details is None:
                    continue
                fill = subject_colors.get(event.subject_code or "", day_color)
                day_blocks.append(DetailBlock(format_title(event, details), "detail_title", fill))
                day_blocks.append(DetailBlock("Місце проведення:", "place", fill))
                for item in details.items or []:
                    day_detail_items.extend(split_training_points(item))

            night_detail_items: list[str] = []
            for event in night_events[source_day]:
                details = lesson_from_event(event, grids)
                if details is None:
                    continue
                day_like_fill = subject_colors.get(event.subject_code or "", day_color)
                fill = darken_color(day_like_fill)
                night_blocks.append(DetailBlock(format_title(event, details), "detail_title", fill))
                night_blocks.append(DetailBlock("Місце проведення:", "place", fill))
                for item in details.items or []:
                    night_detail_items.extend(split_training_points(item))

            payload: list[DetailBlock] = []
            if not day_blocks and night_blocks:
                payload.extend([DetailBlock("", "detail_title"), DetailBlock("", "place")])
            payload.extend(day_blocks)
            if day_blocks and not night_blocks:
                payload.extend([DetailBlock("", "detail_title"), DetailBlock("", "place")])
            payload.extend(night_blocks)
            payload.extend(DetailBlock(item, "body", None, detail_span(item)) for item in day_detail_items)
            payload.extend(DetailBlock(item, "body", DETAIL_FILL, detail_span(item)) for item in night_detail_items)
            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: 72.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)
                needed = estimate_height([block.text], 43, 58 if block.kind == "body" else 82)
                per_row = max(54.0, min(172.0, needed / max(1, block.span) + 4))
                for row_index in range(cursor, cursor + block.span):
                    row_height_needs[row_index] = max(row_height_needs.get(row_index, 72.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(172, 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.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(),
                    "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_range())
        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)
        ttk.Entry(frame, textvariable=self.units).grid(row=6, column=1, sticky="ew", pady=4)
        ttk.Label(frame, text="через запятую: 2НР, 3НР").grid(row=6, column=2, sticky="w")

        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.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.Label(frame, text="Сетки и цвета предметов").grid(row=9, 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", ""))
            color_var = StringVar(value=saved.get("color", ""))
            self.grid_vars.append((code_var, path_var, color_var))
            row = 10 + 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.Label(frame, text=name).grid(row=row, column=5, sticky="w")

        ttk.Button(frame, text="Создать план", command=self.generate).grid(row=17, column=1, 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 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_range()
        except Exception as exc:
            messagebox.showerror("Ошибка", str(exc))

    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 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 main() -> None:
    root = Tk()
    PlanApp(root)
    root.mainloop()


if __name__ == "__main__":
    main()
