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
    kind_label: str = ""   # normalized block type parsed from LG cell, e.g. "Практично"


# 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 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 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]] = []
    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),
                            kind_label=parse_block_kind_from_lg(event_text),
                        )
                    )
        units.append((unit_name, short_name, events))
    return dates, units


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
    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 = day_header_row + 2 if day_header_row else 7
            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, "")

            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
    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 lesson_from_event(event: Event, grids) -> LessonDetails | None:
    if not (event.subject_code and event.day_no and event.subject_code in grids):
        return None
    grid = grids[event.subject_code]
    # Try exact match: (day_no, part, kind_label)
    if event.kind_label:
        detail = grid.get((event.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 (day_no, _part, kind), detail in grid.items():
            if day_no == event.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 k[0] == event.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 k[0] == event.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 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 "Вдень"
        kind = f" ({details.kind_label})" if details.kind_label else ""
        return f"Заняття:\n{subject} День {event.day_no} {part}{kind}"
    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,
                    font_size: float = 10.0) -> float:
    """Estimate row height in points based on text content and font size.
    Cyrillic characters are wider than Latin — use 0.7 * font_size as avg char width.
    Excel column width unit ≈ 7pt at default font (Calibri 11).
    Row height is in points directly."""
    line_height_pt = font_size * 1.5          # generous line spacing
    avg_char_width_pt = font_size * 0.70      # Cyrillic is wider
    col_width_pt = width * 6.5               # col width units → points
    chars_per_line = max(8, int(col_width_pt / avg_char_width_pt))
    total_lines = 0
    for text in texts:
        if not text:
            continue
        for line in str(text).splitlines() or [""]:
            total_lines += max(1, math.ceil(max(1, len(line)) / chars_per_line))
    if total_lines == 0:
        total_lines = 1
    height_pt = total_lines * line_height_pt + 6   # +6pt bottom padding
    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=20)
        cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
    elif kind == "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=10)
        cell.alignment = Alignment(horizontal="left", vertical="top", 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_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_payload: list[list[DetailBlock]] = []
        for source_day in day_order:
            day_blocks: list[DetailBlock] = []
            night_blocks: list[DetailBlock] = []
            day_detail_items: list[str] = []

            seen_day_titles: set[str] = set()
            for event in day_events[source_day]:
                details = lesson_from_event(event, grids)
                if details is None:
                    continue
                title = format_title(event, details)
                if title in seen_day_titles:
                    continue
                seen_day_titles.add(title)
                fill = subject_colors.get(event.subject_code or "", day_color)
                day_blocks.append(DetailBlock(title + "\nМісце проведення:", "detail_title", fill))
                items_seen: set[str] = set()
                for item in details.items or []:
                    for point in split_training_points(item):
                        if point not in items_seen:
                            items_seen.add(point)
                            day_detail_items.append(point)

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

            payload: list[DetailBlock] = []
            if not day_blocks and night_blocks:
                payload.append(DetailBlock("", "detail_title"))
            payload.extend(day_blocks)
            if day_blocks and not night_blocks:
                payload.append(DetailBlock("", "detail_title"))
            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: 30.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)
                font_size = 20.0 if block.kind in ("detail_title", "place") else 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.5, 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.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()
