"""Sync a given sheet's log entries into the Overview sheet in local_content_dir/log_book.xlsx.

Usage (from project root):
    python -m src.utils.sync_to_overview --sheet XH-01

This script:
- Loads local_content_dir/log_book.xlsx
- Reads all rows from the specified sheet
- Applies half-month attribution rules to start/end dates
- Maps each entry onto the Overview sheet's timeline row for that sheet
- Merges cells from the start half-month to the end half-month
- Copies the fill color from the source log_name cell

Assumptions about the workbook layout are documented in the docstrings below.
"""

from dataclasses import dataclass
from datetime import date, datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import xml.etree.ElementTree as ET
from openpyxl.workbook.workbook import Workbook as _Workbook

from openpyxl import load_workbook
from openpyxl.styles import PatternFill, Alignment
from openpyxl.worksheet.worksheet import Worksheet
from openpyxl.cell.cell import Cell, MergedCell

# Paths and constants
PROJECT_ROOT = Path(__file__).resolve().parents[4]
LOG_BOOK_PATH = PROJECT_ROOT / "data" / "KSP_data" / "log_book.xlsx"

OVERVIEW_SHEET_NAME = "Overview"
HEADER_ROW_INDEX = 1
OVERVIEW_TIMELINE_START_COL = 3  # Column C by default (used for horizontal layout)


@dataclass
class LogEntry:
    start: date
    end: date
    name: str
    fill: Optional[PatternFill]


def _normalize_header_name(value: Optional[str]) -> Optional[str]:
    if not isinstance(value, str):
        return None
    v = value.strip().lower().replace("-", "_").replace(" ", "_")
    return v or None


def get_log_sheet_headers(ws: Worksheet, header_row: int = HEADER_ROW_INDEX) -> Dict[str, int]:
    """Return a mapping from logical header names to 1-based column indices.

    Required logical names: start_date, end_date, log_name
    Optional: color
    """
    headers: Dict[str, int] = {}
    for col in range(1, ws.max_column + 1):
        raw = ws.cell(row=header_row, column=col).value
        norm = _normalize_header_name(raw)
        if not norm:
            continue
        if norm in ("start", "start_date", "start_time"):
            headers["start_date"] = col
        elif norm in ("end", "end_date", "end_time"):
            headers["end_date"] = col
        elif norm in ("log_name", "name", "title"):
            headers["log_name"] = col
        elif norm in ("color", "colour", "fill"):
            headers["color"] = col
    required = {"start_date", "end_date", "log_name"}
    missing = required - headers.keys()
    if missing:
        raise ValueError(f"Missing required columns in sheet {ws.title!r}: {sorted(missing)}")
    return headers


def _parse_excel_date(value) -> Optional[date]:
    """Best-effort parse of an Excel cell value into a date.

    Supports datetime/date values and common string formats.
    """
    if value is None or value == "":
        return None
    if isinstance(value, datetime):
        return value.date()
    if isinstance(value, date):
        return value
    if isinstance(value, (int, float)):
        # We avoid depending on workbook epoch; numeric dates are less expected here.
        # Fallback: treat as ordinal days from 1900-01-01 if needed in future.
        return None
    if isinstance(value, str):
        text = value.strip()
        if not text:
            return None
        for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"):
            try:
                return datetime.strptime(text, fmt).date()
            except ValueError:
                continue
    return None


def _apply_tint(argb_hex: str, tint: float) -> str:
    """Apply Excel tint to an ARGB hex string and return ARGB hex.

    Tint formula (approximate):
    - tint in (-1, 1)
    - for each RGB channel (0-255):
        if tint < 0: result = channel * (1.0 + tint)
        else: result = channel + (255 - channel) * tint

    argb_hex may be 8-char (ARGB) or 6-char (RGB) -- we assume ARGB and preserve alpha.
    Returns uppercase 8-char ARGB string.
    """
    s = argb_hex.strip().lstrip("#")
    if len(s) == 6:
        alpha = 255
        rgb = s
    elif len(s) == 8:
        alpha = int(s[0:2], 16)
        rgb = s[2:]
    else:
        return argb_hex.upper()

    try:
        r = int(rgb[0:2], 16)
        g = int(rgb[2:4], 16)
        b = int(rgb[4:6], 16)
    except Exception:
        return argb_hex.upper()

    def mix(channel: int, t: float) -> int:
        if t < 0:
            v = int(round(channel * (1.0 + t)))
        else:
            v = int(round(channel + (255 - channel) * t))
        return max(0, min(255, v))

    r2 = mix(r, tint)
    g2 = mix(g, tint)
    b2 = mix(b, tint)

    return f"{alpha:02X}{r2:02X}{g2:02X}{b2:02X}"


def _coerce_fill(value) -> Optional[PatternFill]:
    """Convert various fill-like objects into a concrete solid PatternFill.

    Rules:
    - Only convert when the source is a solid fill (or has an explicit fgColor rgb/index/theme)
    - Do not convert transparent/empty colors (alpha == '00' or missing) to avoid black blocks
    - Support Color.rgb (ARGB or RGB), Color.indexed via openpyxl.styles.colors.COLOR_INDEX
    - If we can't find a clear solid color, return None so caller won't apply a fill
    """
    if value is None:
        return None

    # If already a concrete PatternFill with solid type and a usable fgColor, try to reuse
    try:
        if isinstance(value, PatternFill):
            ptype = getattr(value, "patternType", None) or getattr(value, "fill_type", None)
            fg = getattr(value, "fgColor", None)
            if ptype and str(ptype).lower() in ("solid", "darkgray", "gray125", "gray0625") and fg is not None:
                rgb = getattr(fg, "rgb", None) or getattr(fg, "value", None)
                if isinstance(rgb, str) and rgb.strip():
                    hex_str = rgb.strip().lstrip("#")
                    if len(hex_str) == 6:
                        hex_str = "FF" + hex_str
                    if len(hex_str) == 8 and not hex_str.startswith("00"):
                        return PatternFill(fill_type="solid", fgColor=hex_str)
            # otherwise fallthrough
    except Exception:
        pass

    # If the object indicates no pattern (no fill), bail out to avoid assigning black
    try:
        ptype = getattr(value, "patternType", None) or getattr(value, "fill_type", None)
        if ptype is None or str(ptype).strip().lower() in ("none", ""):
            return None
    except Exception:
        pass

    # Generic proxy/object with color attributes
    fg = getattr(value, "fgColor", None) or getattr(value, "start_color", None)
    rgb = None
    if fg is not None:
        rgb = getattr(fg, "rgb", None) or getattr(fg, "value", None)
        # try indexed
        if rgb is None:
            idx = getattr(fg, "indexed", None)
            if isinstance(idx, int):
                try:
                    from openpyxl.styles.colors import COLOR_INDEX

                    hex_str = COLOR_INDEX[idx].lstrip("#")
                    if len(hex_str) == 6:
                        hex_str = "FF" + hex_str
                    # avoid default/black colors
                    if len(hex_str) == 8 and not hex_str.startswith("00") and hex_str.upper() != "FF000000":
                        return PatternFill(fill_type="solid", fgColor=hex_str)
                except Exception:
                    pass
        if isinstance(rgb, str) and rgb.strip():
            hex_str = rgb.strip().lstrip("#")
            # normalize to ARGB
            if len(hex_str) == 6:
                hex_str = "FF" + hex_str
            if len(hex_str) == 8 and not hex_str.startswith("00") and hex_str.upper() != "FF000000":
                # apply tint if present
                tint = getattr(fg, "tint", None)
                if tint is not None:
                    try:
                        hex_str = _apply_tint(hex_str, float(tint))
                    except Exception:
                        pass
                return PatternFill(fill_type="solid", fgColor=hex_str)
        # theme-based color (fg.theme)
        theme_idx = getattr(fg, "theme", None)
        if theme_idx is not None and _CURRENT_WB is not None:
            try:
                global _THEME_COLORS_CACHE
                if _THEME_COLORS_CACHE is None:
                    _THEME_COLORS_CACHE = _load_theme_colors(_CURRENT_WB)
                if isinstance(theme_idx, int) and _THEME_COLORS_CACHE and theme_idx < len(_THEME_COLORS_CACHE):
                    theme_hex = _THEME_COLORS_CACHE[theme_idx]
                    if theme_hex and theme_hex.upper() != "FF000000":
                        tint = getattr(fg, "tint", None)
                        if tint is not None:
                            try:
                                theme_hex = _apply_tint(theme_hex, float(tint))
                            except Exception:
                                pass
                        return PatternFill(fill_type="solid", fgColor=theme_hex)
            except Exception:
                pass

    # As a last-ditch, the object itself might expose a string color
    valstr = None
    try:
        if isinstance(value, str):
            valstr = value
        else:
            valstr = str(value)
    except Exception:
        valstr = None
    if isinstance(valstr, str):
        s = valstr.strip().lstrip("#")
        if len(s) == 6:
            hex_str = "FF" + s
            if hex_str.upper() != "FF000000":
                return PatternFill(fill_type="solid", fgColor=hex_str)
        if len(s) == 8 and not s.startswith("00") and s.upper() != "FF000000":
            return PatternFill(fill_type="solid", fgColor=s)

    return None


def _load_theme_colors(wb: _Workbook) -> List[str]:
    """Parse workbook.loaded_theme XML and return list of SRGB ARGB hex strings in scheme order.

    This is best-effort. We look for <a:clrScheme> children and for each child prefer 'srgbClr' @val.
    Returned hex strings are normalized to ARGB (8 chars, FF prefixed when needed).
    """
    colors: List[str] = []
    try:
        data = getattr(wb, "loaded_theme", None)
        if data is None:
            return colors
        if isinstance(data, bytes):
            data = data.decode("utf-8")
        root = ET.fromstring(data)
        # find any clrScheme element
        clr = None
        for node in root.findall('.//'):
            tag = node.tag
            if tag.endswith('clrScheme'):
                clr = node
                break
        if clr is None:
            return colors
        for child in list(clr):
            # find srgbClr descendant
            srgb = child.find('.//')
            val = None
            # search for any element with 'srgbClr' in tag
            for desc in child.iter():
                if desc.tag.endswith('srgbClr'):
                    val = desc.get('val')
                    break
            if val:
                s = val.strip().lstrip('#')
                if len(s) == 6:
                    colors.append('FF' + s.upper())
                elif len(s) == 8:
                    colors.append(s.upper())
                else:
                    colors.append('')
            else:
                colors.append('')
    except Exception:
        return []
    return colors


def parse_log_row(ws: Worksheet, row_index: int, headers: Dict[str, int]) -> Optional[LogEntry]:
    start_cell: Cell = ws.cell(row=row_index, column=headers["start_date"])
    end_cell: Cell = ws.cell(row=row_index, column=headers["end_date"])
    name_cell: Cell = ws.cell(row=row_index, column=headers["log_name"])

    start = _parse_excel_date(start_cell.value)
    end = _parse_excel_date(end_cell.value)
    name_raw = name_cell.value
    name = str(name_raw).strip() if name_raw is not None else ""

    if start is None and end is None and not name:
        return None

    if start is None and end is not None:
        start = end
    if end is None and start is not None:
        end = start
    if start is None or end is None:
        # Still not enough info
        return None

    fill: Optional[PatternFill] = None
    if "color" in headers:
        color_cell = ws.cell(row=row_index, column=headers["color"])
        # Here we trust the user to put a proper hex like RRGGBB; minimal handling only.
        if isinstance(color_cell.value, str) and color_cell.value.strip():
            hex_str = color_cell.value.strip().lstrip("#")
            if len(hex_str) in (6, 8):
                # openpyxl expects ARGB; if RGB given, prepend FF
                if len(hex_str) == 6:
                    hex_str = "FF" + hex_str
                fill = PatternFill(fill_type="solid", fgColor=hex_str)
    if fill is None:
        # annotate name_cell.fill access for static checkers
        nf = name_cell.fill
        fill = _coerce_fill(nf)
        if nf is not None and fill is None:
            # Debug info: collect fgColor attributes to help diagnose why coercion failed
            try:
                fg = getattr(nf, "fgColor", None) or getattr(nf, "start_color", None)
                rgb = getattr(fg, "rgb", None) if fg is not None else None
                theme = getattr(fg, "theme", None) if fg is not None else None
                indexed = getattr(fg, "indexed", None) if fg is not None else None
                tint = getattr(fg, "tint", None) if fg is not None else None
                print(f"[sync_to_overview] sheet={ws.title!r} row={row_index} log_name={name!r} had fill proxy but coercion failed: fg.rgb={rgb!r} fg.theme={theme!r} fg.indexed={indexed!r} fg.tint={tint!r}")
            except Exception:
                print(f"[sync_to_overview] sheet={ws.title!r} row={row_index} log_name={name!r} had fill proxy but coercion failed (unable to inspect attributes)")

    if not name:
        # If there is no name but valid dates, we still can render a colored block; use empty string.
        name = ""

    return LogEntry(start=start, end=end, name=name, fill=fill)


def _shift_month(d: date, delta: int) -> date:
    """Shift date by delta months, keeping day when possible.

    If the target month has fewer days, clamp to last day of month.
    """
    month = d.month - 1 + delta
    year = d.year + month // 12
    month = month % 12 + 1
    # Clamp day to last day of target month
    from calendar import monthrange

    last_day = monthrange(year, month)[1]
    day = min(d.day, last_day)
    return date(year, month, day)


def attribute_start_date(d: date) -> date:
    """Map start date to its month start.

    New rule: the start month is always the month of the original start date.
    Returns a date representing the first day of that month (for timeline bucket comparisons).
    """
    return date(d.year, d.month, 1)


def attribute_end_date(d: date) -> date:
    """Map end date to its month start.

    New rule: the end month is the month containing the actual end date (no half-month cutoff).
    Returns the first day of that month for timeline comparisons.
    """
    return date(d.year, d.month, 1)


# --- New: support both horizontal (timeline across a header row) and vertical (timeline down column A) ---

def _detect_overview_orientation(ws: Worksheet, header_row: int = HEADER_ROW_INDEX, start_col: int = OVERVIEW_TIMELINE_START_COL) -> str:
    """Detect whether Overview worksheet uses a horizontal timeline (dates across header_row)
    or a vertical timeline (dates down column A). Returns 'horizontal' or 'vertical'.
    Simple heuristic: if column A (starting at row 2) contains many dates -> vertical.
    Otherwise if header_row contains many dates across columns starting at start_col -> horizontal.
    Defaults to 'horizontal'.
    """
    # check column A for dates
    col_a_date_count = 0
    for r in range(header_row + 1, min(ws.max_row, header_row + 200) + 1):
        v = ws.cell(row=r, column=1).value
        if _parse_excel_date(v) is not None:
            col_a_date_count += 1
            if col_a_date_count >= 3:
                return "vertical"
    # check header row for dates across columns
    row_date_count = 0
    for c in range(start_col, min(ws.max_column, start_col + 200) + 1):
        v = ws.cell(row=header_row, column=c).value
        if _parse_excel_date(v) is not None:
            row_date_count += 1
            if row_date_count >= 3:
                return "horizontal"
    return "horizontal"


def get_overview_timeline(
    ws: Worksheet,
    header_row: int = HEADER_ROW_INDEX,
    start_col: int = OVERVIEW_TIMELINE_START_COL,
) -> Tuple[str, List[Tuple[int, date]]]:
    """Extract overview timeline.

    Returns (orientation, timeline) where orientation is 'horizontal' or 'vertical'.
    - horizontal: timeline is list of (col_index, date) scanning header_row from start_col rightwards
    - vertical: timeline is list of (row_index, date) scanning column A from header_row+1 downward
    """
    orientation = _detect_overview_orientation(ws, header_row, start_col)
    timeline: List[Tuple[int, date]] = []

    if orientation == "horizontal":
        empty_streak = 0
        max_empty_streak = 10
        for col in range(start_col, ws.max_column + 1):
            cell = ws.cell(row=header_row, column=col)
            d = _parse_excel_date(cell.value)
            if d is None:
                empty_streak += 1
                if empty_streak >= max_empty_streak:
                    break
                continue
            empty_streak = 0
            timeline.append((col, d))
        timeline.sort(key=lambda x: x[1])
        return orientation, timeline

    # vertical
    empty_streak = 0
    max_empty_streak = 10
    for row in range(header_row + 1, ws.max_row + 1):
        cell = ws.cell(row=row, column=1)
        d = _parse_excel_date(cell.value)
        if d is None:
            empty_streak += 1
            if empty_streak >= max_empty_streak:
                break
            continue
        empty_streak = 0
        timeline.append((row, d))
    timeline.sort(key=lambda x: x[1])
    return orientation, timeline


def find_bucket_span(
    timeline: List[Tuple[int, date]], start_attr: date, end_attr: date
) -> Optional[Tuple[int, int]]:
    if not timeline:
        return None
    # Match by year-month to be robust when timeline cells are mid-month
    start_key = (start_attr.year, start_attr.month)
    end_key = (end_attr.year, end_attr.month)

    ym_list: List[Tuple[int, Tuple[int, int]]] = [(idx, (d.year, d.month)) for idx, d in timeline]

    first_pos = None
    for i, (_, ym) in enumerate(ym_list):
        if ym >= start_key:
            first_pos = i
            break
    if first_pos is None:
        return None

    last_pos = None
    for i in range(len(ym_list) - 1, -1, -1):
        if ym_list[i][1] <= end_key:
            last_pos = i
            break
    if last_pos is None or last_pos < first_pos:
        return None

    return timeline[first_pos][0], timeline[last_pos][0]


def _has_merged_intersection(ws: Worksheet, min_row: int, max_row: int, min_col: int, max_col: int) -> bool:
    """Return True if any existing merged cell range intersects the supplied rectangle."""
    for merged in ws.merged_cells.ranges:
        if merged.min_row <= max_row and merged.max_row >= min_row and merged.min_col <= max_col and merged.max_col >= min_col:
            return True
    return False


def _unmerge_intersecting_ranges(ws: Worksheet, min_row: int, max_row: int, min_col: int, max_col: int) -> None:
    to_unmerge = []
    for merged in list(ws.merged_cells.ranges):
        if merged.min_row <= max_row and merged.max_row >= min_row and merged.min_col <= max_col and merged.max_col >= min_col:
            to_unmerge.append(merged)
    for merged in to_unmerge:
        ws.unmerge_cells(str(merged.coord))


def clear_overview_range(ws: Worksheet, sheet_pos: int, indices: List[int], orientation: str) -> None:
    """Clear the destination cells for a given sheet in the Overview.

    - For horizontal orientation: sheet_pos is a row index, indices are column indices.
    - For vertical orientation: sheet_pos is a column index, indices are row indices.
    """
    if not indices:
        return
    min_idx = min(indices)
    max_idx = max(indices)

    if orientation == "horizontal":
        min_col = min_idx
        max_col = max_idx
        row_index = sheet_pos
        # Unmerge any merged cells intersecting this row & column range
        _unmerge_intersecting_ranges(ws, row_index, row_index, min_col, max_col)
        for col in range(min_col, max_col + 1):
            cell: Any = ws.cell(row=row_index, column=col)
            cell.value = None
            cell.fill = PatternFill()  # clear fill
    else:
        # vertical orientation: we clear a column at sheet_pos between min_idx..max_idx rows
        col_index = sheet_pos
        min_row = min_idx
        max_row = max_idx
        _unmerge_intersecting_ranges(ws, min_row, max_row, col_index, col_index)
        for row in range(min_row, max_row + 1):
            cell: Any = ws.cell(row=row, column=col_index)
            cell.value = None
            cell.fill = PatternFill()  # clear fill


def apply_log_to_overview(
    ws: Worksheet,
    sheet_pos: int,
    start_idx: int,
    end_idx: int,
    log_name: str,
    fill: Optional[PatternFill],
    orientation: str,
) -> None:
    """Apply a log entry to the Overview.

    - For horizontal: sheet_pos is row, start_idx/end_idx are columns -> merge horizontally (single row).
    - For vertical: sheet_pos is column, start_idx/end_idx are rows -> merge vertically (single column).
    """
    if start_idx > end_idx:
        start_idx, end_idx = end_idx, start_idx

    # Conflict resolution: if any existing merged range intersects our intended target, shrink the start index forward
    if orientation == "horizontal":
        # target rectangle: (sheet_pos, start_idx..end_idx)
        while start_idx <= end_idx and _has_merged_intersection(ws, sheet_pos, sheet_pos, start_idx, end_idx):
            start_idx += 1
        if start_idx > end_idx:
            return
        if start_idx == end_idx:
            cell: Any = ws.cell(row=sheet_pos, column=start_idx)
        else:
            ws.merge_cells(start_row=sheet_pos, start_column=start_idx, end_row=sheet_pos, end_column=end_idx)
            cell: Any = ws.cell(row=sheet_pos, column=start_idx)
    else:
        # vertical: target rectangle: (start_idx..end_idx, sheet_pos)
        while start_idx <= end_idx and _has_merged_intersection(ws, start_idx, end_idx, sheet_pos, sheet_pos):
            start_idx += 1
        if start_idx > end_idx:
            return
        if start_idx == end_idx:
            cell: Any = ws.cell(row=start_idx, column=sheet_pos)
        else:
            ws.merge_cells(start_row=start_idx, start_column=sheet_pos, end_row=end_idx, end_column=sheet_pos)
            cell: Any = ws.cell(row=start_idx, column=sheet_pos)

    # Defensive: if we somehow got a MergedCell (read-only), find the actual top-left cell
    try:
        if isinstance(cell, MergedCell):
            for merged in ws.merged_cells.ranges:
                if merged.min_row <= cell.row <= merged.max_row and merged.min_col <= cell.column <= merged.max_col:
                    cell = ws.cell(row=merged.min_row, column=merged.min_col)
                    break
    except Exception:
        # ignore and continue
        pass

    cell.value = log_name
    cell.alignment = Alignment(horizontal="center", vertical="center")  # type: ignore[attr-defined]
    if fill is not None:
        cell.fill = fill  # type: ignore[attr-defined]


def _find_overview_position_for_sheet(ws: Worksheet, sheet_name: str, orientation: str) -> Optional[int]:
    """Find where the sheet's overview cells live.

    - horizontal orientation: sheet names are expected in column A (like before) -> return the row index
    - vertical orientation: sheet names are expected in header row (ROW 1) across columns -> return the column index
    """
    if orientation == "horizontal":
        for row in range(2, ws.max_row + 1):  # assume header in row 1
            val = ws.cell(row=row, column=1).value
            if isinstance(val, str) and val.strip() == sheet_name:
                return row
        return None
    # vertical
    for col in range(2, ws.max_column + 1):  # assume column A is timeline, so sheet headers start at B
        val = ws.cell(row=HEADER_ROW_INDEX, column=col).value
        if isinstance(val, str) and val.strip() == sheet_name:
            return col
    return None


def sync_to_overview(source_sheet_name: str, workbook_path: Optional[Path] = None) -> None:
    global _CURRENT_WB, _THEME_COLORS_CACHE
    wb_path = Path(workbook_path) if workbook_path is not None else LOG_BOOK_PATH
    if not wb_path.exists():
        raise FileNotFoundError(f"Workbook not found: {wb_path}")

    wb = load_workbook(wb_path, data_only=False)
    _CURRENT_WB = wb
    _THEME_COLORS_CACHE = None

    if source_sheet_name not in wb.sheetnames:
        raise KeyError(f"Sheet not found: {source_sheet_name}")
    if OVERVIEW_SHEET_NAME not in wb.sheetnames:
        raise KeyError(f"Overview sheet not found: {OVERVIEW_SHEET_NAME}")

    ws_src = wb[source_sheet_name]
    ws_ov = wb[OVERVIEW_SHEET_NAME]

    orientation, timeline = get_overview_timeline(ws_ov)
    timeline_indices = [idx for idx, _ in timeline]

    overview_pos = _find_overview_position_for_sheet(ws_ov, source_sheet_name, orientation)
    if overview_pos is None:
        if orientation == "horizontal":
            raise ValueError(f"Could not find row in Overview with name {source_sheet_name!r} in column A")
        else:
            raise ValueError(f"Could not find column in Overview with header {source_sheet_name!r} in row 1")

    headers = get_log_sheet_headers(ws_src)

    # Clear the destination range first
    clear_overview_range(ws_ov, overview_pos, timeline_indices, orientation)

    for row in range(HEADER_ROW_INDEX + 1, ws_src.max_row + 1):
        entry = parse_log_row(ws_src, row, headers)
        if entry is None:
            continue
        start_attr = attribute_start_date(entry.start)
        end_attr = attribute_end_date(entry.end)
        if end_attr < start_attr:
            end_attr = start_attr
        span = find_bucket_span(timeline, start_attr, end_attr)
        if span is None:
            continue
        idx_start, idx_end = span
        apply_log_to_overview(ws_ov, overview_pos, idx_start, idx_end, entry.name, entry.fill, orientation)

    wb.save(wb_path)
    # clear global
    _CURRENT_WB = None
    _THEME_COLORS_CACHE = None


def main(sheet: str, workbook: Optional[str] = None) -> None:
    """Run sync_to_overview with explicit parameters.

    Args:
        sheet: source sheet name (e.g. 'XH-01')
        workbook: path to workbook file; if None, use default LOG_BOOK_PATH
    """
    wb_path = Path(workbook) if workbook is not None else LOG_BOOK_PATH
    sync_to_overview(sheet, wb_path)


if __name__ == "__main__":
    # Configure these values here and call main directly.
    # Edit these lines to change which sheet / workbook are processed.
    sheet_name = "ST-01"
    workbook_path = str(LOG_BOOK_PATH)
    main(sheet_name, workbook_path)
