#!/usr/bin/env python3
"""
Combine multiple CSV files that share the same structure and export to CSV or XLSX.

Highlights
- File picker to select multiple CSV inputs
- Safe delimiter detection with fallbacks and mixed encoding handling
- Column normalization and alignment to the first successfully read file
- Optional duplicate removal
- Progress dialog with ETA, percent, log, and Cancel
- Windows DPI awareness so the UI is crisp on Windows 11
- Output chooser with format enforcement: CSV or XLSX
- Optional worksheet edit password for XLSX. This is protection from edits, not encryption.
- XLSX integrity checks:
  1) choose a compatible engine (xlsxwriter then openpyxl)
  2) write to a temporary file, then atomically replace
  3) verify the final file is a valid ZIP before reporting success
"""

import os
import io
import sys
import csv
import time
import math
import queue
import zipfile
import shutil
import ctypes
import tempfile
import threading
from typing import List, Tuple, Optional, Set

import pandas as pd

# GUI imports
try:
    import tkinter as tk
    from tkinter import ttk, filedialog, simpledialog, messagebox
except Exception:
    tk = None
    ttk = None
    filedialog = None
    simpledialog = None
    messagebox = None

# ----------------------------
# Configuration
# ----------------------------
REMOVE_DUPLICATES = True
DUPLICATE_SUBSET: Optional[List[str]] = None   # None means consider all columns
ENCODING_TRY_ORDER = ["utf-8", "utf-8-sig", "cp1250", "cp852", "latin-1"]
ON_BAD_LINES = "warn"  # "skip", "warn", or "error"
DEFAULT_OUTPUT_BASENAME = "combined"
DEFAULT_XLSX_SHEET_NAME = "Combined"

I18N = {
    "en": {
        "title": "Combine CSV files",
        "select_input": "Select input files",
        "output_format": "Output format:",
        "choose_output": "Choose output location",
        "no_output": "No output selected",
        "idle": "Idle",
        "cancel": "Cancel",
        "starting_status": "Starting...",
        "canceling_status": "Canceling...",
        "completed": "Completed",
        "failed": "Failed",
        "lang": "Language:",
        "lang_en": "English",
        "lang_pl": "Polski",
        "dlg_select_csv": "Select CSV files to combine",
        "dlg_no_files_title": "No files",
        "dlg_no_files_msg": "No input files selected.",
        "dlg_ready_title": "Ready",
        "dlg_ready_msg": "{count} files selected.\nStart combining now?",
        "dlg_optional_pwd_title": "Optional password",
        "dlg_optional_pwd_msg": "Do you want to require a password to edit the XLSX worksheet?\nThis is not encryption. The file will open, but edits require the password.",
        "dlg_pwd_title": "Worksheet password",
        "dlg_pwd_prompt": "Enter password",
        "dlg_choose_output": "Choose output location",
        "err_need_input": "Please select input files first.",
        "err_need_output": "Please choose an output location.",
        "err_no_output_title": "No output",
        "success_title": "Success",
        "success_msg": "Combined file saved to:\n{path}",
        "fail_title": "Combine failed",
        "worker_start": "Starting. {n} files selected.",
        "worker_canceled": "Canceled by user.",
        "worker_all_failed": "All reads failed. No output written.\n\nDetails:\n{details}",
        "worker_read_error": "Error reading {name}. Continuing.",
        "worker_load_progress": "Loaded {idx}/{n}. {eta}",
        "worker_done_no_selection": "No files selected.\n",
        "worker_perm_error": "Permission error. Is the output file open in Excel or blocked by antivirus? {err}\n\nDetails:\n{details}",
        "worker_unexpected": "Unexpected failure: {err}\n\nDetails:\n{details}",
        "worker_saved": "Output: {path}",
        "worker_concat": "Concatenated rows: {rows}",
        "worker_dups_removed": "Duplicates removed: {rows}",
        "worker_header_mismatch": "[{idx}/{n}] {name} header mismatch. Align to {ref}",
        "worker_ok": "[{idx}/{n}] {name} OK | enc {enc} | sep {sep} | rows {rows} | bad {bad}",
        "worker_error_line": "[{idx}/{n}] ERROR {name}: {err}",
        "worker_enc_used": "Encodings used: {items}",
        "worker_delim_used": "Delimiters used: {items}",
        "status_percent": "{percent}% complete. {msg}",
    },
    "pl": {
        "title": "Laczenie plikow CSV",
        "select_input": "Wybierz pliki wejsciowe",
        "output_format": "Format wyjscia:",
        "choose_output": "Wybierz lokalizacje wyjscia",
        "no_output": "Nie wybrano pliku wyjsciowego",
        "idle": "Gotowe",
        "cancel": "Anuluj",
        "starting_status": "Start...",
        "canceling_status": "Anulowanie...",
        "completed": "Zakonczono",
        "failed": "Blad",
        "lang": "Jezyk:",
        "lang_en": "English",
        "lang_pl": "Polski",
        "dlg_select_csv": "Wybierz pliki CSV do polaczenia",
        "dlg_no_files_title": "Brak plikow",
        "dlg_no_files_msg": "Nie wybrano plikow wejsciowych.",
        "dlg_ready_title": "Gotowe",
        "dlg_ready_msg": "Wybrano plikow: {count}.\nUruchomic laczenie teraz?",
        "dlg_optional_pwd_title": "Haslo opcjonalne",
        "dlg_optional_pwd_msg": "Czy wymagac hasla do edycji arkusza XLSX?\nTo nie jest szyfrowanie. Plik otworzy sie normalnie, ale edycja bedzie chroniona.",
        "dlg_pwd_title": "Haslo arkusza",
        "dlg_pwd_prompt": "Podaj haslo",
        "dlg_choose_output": "Wybierz lokalizacje wyjscia",
        "err_need_input": "Najpierw wybierz pliki wejsciowe.",
        "err_need_output": "Najpierw wybierz lokalizacje pliku wyjsciowego.",
        "err_no_output_title": "Brak wyjscia",
        "success_title": "Sukces",
        "success_msg": "Plik wynikowy zapisano do:\n{path}",
        "fail_title": "Laczenie nieudane",
        "worker_start": "Start. Wybrano plikow: {n}.",
        "worker_canceled": "Anulowano przez uzytkownika.",
        "worker_all_failed": "Wszystkie odczyty nieudane. Nie zapisano wyniku.\n\nSzczegoly:\n{details}",
        "worker_read_error": "Blad odczytu pliku {name}. Kontynuuje.",
        "worker_load_progress": "Wczytano {idx}/{n}. {eta}",
        "worker_done_no_selection": "Nie wybrano plikow.\n",
        "worker_perm_error": "Blad uprawnien. Czy plik wyjsciowy jest otwarty w Excelu lub blokowany? {err}\n\nSzczegoly:\n{details}",
        "worker_unexpected": "Nieoczekiwany blad: {err}\n\nSzczegoly:\n{details}",
        "worker_saved": "Wyjscie: {path}",
        "worker_concat": "Polaczone wiersze: {rows}",
        "worker_dups_removed": "Usuniete duplikaty: {rows}",
        "worker_header_mismatch": "[{idx}/{n}] {name} - niezgodny naglowek. Wyrownuje do {ref}",
        "worker_ok": "[{idx}/{n}] {name} OK | kodowanie {enc} | separator {sep} | wiersze {rows} | bledne {bad}",
        "worker_error_line": "[{idx}/{n}] BLAD {name}: {err}",
        "worker_enc_used": "Uzyte kodowania: {items}",
        "worker_delim_used": "Uzyte separatory: {items}",
        "status_percent": "{percent}% ukonczono. {msg}",
    },
}

# ----------------------------
# Windows DPI awareness
# ----------------------------
def make_dpi_aware_if_windows() -> None:
    """Help Tk render crisply on high DPI Windows displays."""
    try:
        if sys.platform.startswith("win"):
            try:
                ctypes.windll.shcore.SetProcessDpiAwareness(2)
            except Exception:
                try:
                    ctypes.windll.user32.SetProcessDPIAware()
                except Exception:
                    pass
    except Exception:
        pass

# ----------------------------
# Helpers
# ----------------------------
def normalize_columns(cols: List[str]) -> List[str]:
    """Strip BOM, trim, collapse inner whitespace in column headers."""
    def _c(x: str) -> str:
        x = x.replace("\ufeff", "")
        x = " ".join(x.strip().split())
        return x
    return [_c(str(c)) for c in cols]

def sniff_delimiter(sample: str) -> Optional[str]:
    """Guess delimiter based on a sample of text."""
    try:
        dialect = csv.Sniffer().sniff(sample, delimiters=[",", ";", "\t", "|"])
        return dialect.delimiter
    except Exception:
        pass
    candidates = [",", ";", "\t", "|"]
    counts = {d: sample.count(d) for d in candidates}
    best = max(counts, key=counts.get)
    return best if counts[best] > 0 else None

def read_with_strategies(path: str) -> Tuple[pd.DataFrame, str, str, int]:
    """
    Read a CSV using multiple encodings and delimiter strategies.
    Returns df, encoding_used, delimiter_used, bad_lines_count.
    """
    last_err = None

    # Take a small sample to help delimiter guess
    with open(path, "rb") as f:
        raw = f.read(64 * 1024)
    sample_utf8 = raw.decode("utf-8", errors="ignore")

    preferred = sniff_delimiter(sample_utf8)
    delim_candidates = []
    if preferred:
        delim_candidates.append(preferred)
    for d in [",", ";", "\t", "|"]:
        if d not in delim_candidates:
            delim_candidates.append(d)

    def make_bad_line_handler():
        counter = {"n": 0}
        def handler(_bad_line):
            counter["n"] += 1
            if ON_BAD_LINES == "error":
                raise ValueError("Malformed line encountered")
            return  # engine=python will skip the bad line
        return handler, counter

    for enc in ENCODING_TRY_ORDER:
        for delim in [None] + delim_candidates:
            engine_try = ["python"] if delim is None else ["c", "python"]
            for eng in engine_try:
                try:
                    bad_count = 0
                    kwargs = dict(
                        engine=eng,
                        dtype=None,
                        encoding=enc,
                        quoting=csv.QUOTE_MINIMAL,
                        quotechar='"',
                        escapechar="\\",
                        encoding_errors="replace",
                    )
                    if delim is None:
                        kwargs["sep"] = None
                    else:
                        kwargs["sep"] = delim

                    if eng == "python":
                        on_bad_lines_fn, ctr = make_bad_line_handler()
                        kwargs["on_bad_lines"] = on_bad_lines_fn
                        bad_count = ctr["n"]
                    else:
                        kwargs["on_bad_lines"] = "error" if ON_BAD_LINES == "error" else "skip"
                        bad_count = -1

                    df = pd.read_csv(path, **kwargs)
                    if eng == "python":
                        bad_count = ctr["n"]
                    df.columns = normalize_columns(list(df.columns))
                    return df, enc, (delim if delim else "auto"), bad_count
                except Exception as e:
                    last_err = e
                    continue
    raise RuntimeError(f"Failed to read {os.path.basename(path)}. Last error: {last_err}")

def align_to_reference(df: pd.DataFrame, ref_cols: List[str]) -> pd.DataFrame:
    """Force the DataFrame to the reference schema. Fill missing columns with NA, drop extras."""
    for c in ref_cols:
        if c not in df.columns:
            df[c] = pd.NA
    return df[ref_cols]

def fmt_int(n: int) -> str:
    return f"{n:,}"

def estimate_eta(start_ts: float, progress: float) -> str:
    if progress <= 0:
        return "ETA calculating"
    elapsed = time.time() - start_ts
    total = elapsed / progress
    remain = max(0.0, total - elapsed)
    mins, secs = divmod(int(remain), 60)
    return f"ETA {mins:02d}:{secs:02d}"

def force_extension(path: str, fmt: str) -> str:
    """Ensure the filename extension matches the chosen format."""
    base, _ext = os.path.splitext(path)
    if fmt == "xlsx":
        return base + ".xlsx"
    return base + ".csv"

def ensure_xlsx_engine() -> str:
    """
    Pick a working Excel writer engine and report a clear error if none are available.
    Preference: xlsxwriter, then openpyxl.
    """
    try:
        import xlsxwriter  # noqa: F401
        return "xlsxwriter"
    except Exception:
        pass
    try:
        import openpyxl  # noqa: F401
        return "openpyxl"
    except Exception:
        pass
    raise RuntimeError("No Excel engine available. Install xlsxwriter or openpyxl.")

def atomic_replace(src: str, dst: str) -> None:
    """Replace dst with src, falling back to copy+replace across different volumes."""
    try:
        os.replace(src, dst)
        return
    except OSError as e:
        exdev = getattr(os, "EXDEV", 18)
        if getattr(e, "winerror", None) != 17 and e.errno != exdev:
            raise

    dst_dir = os.path.dirname(os.path.abspath(dst)) or "."
    os.makedirs(dst_dir, exist_ok=True)
    suffix = os.path.splitext(dst)[1]
    fd, staging = tempfile.mkstemp(prefix=".tmp_move_", suffix=suffix, dir=dst_dir)
    os.close(fd)
    moved = False
    try:
        shutil.copy2(src, staging)
        os.replace(staging, dst)
        moved = True
    finally:
        if os.path.exists(staging):
            try:
                os.remove(staging)
            except Exception:
                pass
        if moved:
            try:
                os.remove(src)
            except Exception:
                pass

def validate_xlsx(path: str) -> None:
    """
    Validate that the saved file is a real XLSX by checking it is a readable ZIP archive
    and contains the expected core parts.
    """
    if not os.path.exists(path) or os.path.getsize(path) == 0:
        raise RuntimeError("Output file was not created or is empty.")
    if not zipfile.is_zipfile(path):
        raise RuntimeError("The saved file is not a valid XLSX container.")
    with zipfile.ZipFile(path, "r") as zf:
        required = ["[Content_Types].xml", "xl/workbook.xml"]
        missing = [p for p in required if p not in zf.namelist()]
        if missing:
            raise RuntimeError(f"XLSX structure check failed. Missing parts: {', '.join(missing)}")

# ----------------------------
# Worker
# ----------------------------
class CombineWorker(threading.Thread):
    """
    Background worker. Reads files, combines data, and writes output.
    The GUI polls a queue for status updates.
    """
    def __init__(self, paths: List[str], out_path: str, fmt: str, xlsx_password: Optional[str], q: "queue.Queue", cancel_flag: threading.Event, lang: str = "en"):
        super().__init__(daemon=True)
        self.paths = paths
        self.out_path = out_path
        self.fmt = fmt.lower()  # csv or xlsx
        self.xlsx_password = xlsx_password
        self.q = q
        self.cancel_flag = cancel_flag
        self.lang = lang if lang in I18N else "en"

    def run(self):
        log = io.StringIO()
        tr = I18N.get(self.lang, I18N["en"])
        try:
            if not self.paths:
                self.q.put(("done", None, tr["worker_done_no_selection"]))
                return

            start_ts = time.time()
            n = len(self.paths)
            self.q.put(("status", None, tr["worker_start"].format(n=n)))

            combined = []
            enc_used: Set[str] = set()
            delim_used: Set[str] = set()
            ref_cols: Optional[List[str]] = None
            ref_path: Optional[str] = None

            for idx, p in enumerate(self.paths, start=1):
                if self.cancel_flag.is_set():
                    self.q.put(("done", None, tr["worker_canceled"]))
                    return
                try:
                    df, enc, delim, bad = read_with_strategies(p)
                    enc_used.add(enc)
                    delim_used.add(delim)
                    bad_text = "n/a" if bad < 0 else str(bad)

                    if ref_cols is None:
                        ref_cols = list(df.columns)
                        ref_path = os.path.basename(p)
                        print(
                            tr["worker_ok"].format(
                                idx=idx, n=n, name=os.path.basename(p), enc=enc, sep=delim, rows=fmt_int(len(df)), bad=bad_text
                            ),
                            file=log
                        )
                    else:
                        if list(df.columns) != ref_cols:
                            print(tr["worker_header_mismatch"].format(idx=idx, n=n, name=os.path.basename(p), ref=ref_path), file=log)
                            df = align_to_reference(df, ref_cols)
                        print(
                            tr["worker_ok"].format(
                                idx=idx, n=n, name=os.path.basename(p), enc=enc, sep=delim, rows=fmt_int(len(df)), bad=bad_text
                            ),
                            file=log
                        )

                    combined.append(df)
                    progress = idx / n
                    self.q.put(("progress", progress, tr["worker_load_progress"].format(idx=idx, n=n, eta=estimate_eta(start_ts, progress))))
                except Exception as e:
                    print(tr["worker_error_line"].format(idx=idx, n=n, name=os.path.basename(p), err=e), file=log)
                    self.q.put(("status", None, tr["worker_read_error"].format(name=os.path.basename(p))))

            if not combined:
                self.q.put(("done", None, tr["worker_all_failed"].format(details=log.getvalue())))
                return

            if self.cancel_flag.is_set():
                self.q.put(("done", None, tr["worker_canceled"]))
                return

            # Concatenate and optional de-dup
            if len(combined) == 1:
                result = combined[0]
            else:
                result = pd.concat(combined, ignore_index=True, copy=False)
            print(tr["worker_concat"].format(rows=fmt_int(len(result))), file=log)

            if REMOVE_DUPLICATES:
                before = len(result)
                result = result.drop_duplicates(subset=DUPLICATE_SUBSET, keep="first").reset_index(drop=True)
                removed = before - len(result)
                print(tr["worker_dups_removed"].format(rows=fmt_int(removed)), file=log)

            # Save CSV or XLSX with safety checks
            if self.fmt == "csv":
                tmp = self.out_path + ".tmp"
                result.to_csv(tmp, index=False, encoding="utf-8-sig")
                atomic_replace(tmp, self.out_path)
                print(tr["worker_saved"].format(path=self.out_path), file=log)

            elif self.fmt == "xlsx":
                engine = ensure_xlsx_engine()
                # Always write to a temporary file first
                tmp_dir = tempfile.mkdtemp(prefix="combine_xlsx_")
                tmp_path = os.path.join(tmp_dir, "out.xlsx")
                try:
                    with pd.ExcelWriter(tmp_path, engine=engine) as writer:
                        result.to_excel(writer, index=False, sheet_name=DEFAULT_XLSX_SHEET_NAME)
                        if engine == "xlsxwriter":
                            workbook = writer.book
                            worksheet = writer.sheets[DEFAULT_XLSX_SHEET_NAME]
                            if self.xlsx_password:
                                worksheet.protect(self.xlsx_password, options={"insert_rows": False, "insert_columns": False})
                            worksheet.autofilter(0, 0, max(0, len(result)), max(0, len(result.columns) - 1))
                            worksheet.freeze_panes(1, 0)
                        elif engine == "openpyxl":
                            # openpyxl does not support true sheet protection password hashing exactly the same as Excel's UI,
                            # but it can set protection flags. For simple edit protection, set protection if password provided.
                            if self.xlsx_password:
                                ws = writer.book[DEFAULT_XLSX_SHEET_NAME]
                                ws.protection.sheet = True
                                ws.protection.set_password(self.xlsx_password)
                    # Validate XLSX structure before replacing the target file
                    validate_xlsx(tmp_path)
                    # Replace the destination atomically
                    atomic_replace(tmp_path, self.out_path)
                    print(f"{tr['worker_saved'].format(path=self.out_path)} (engine {engine})", file=log)
                    if self.xlsx_password:
                        print("Worksheet protection enabled. Note: this is not file encryption.", file=log)
                finally:
                    # Clean up temp directory if it still exists
                    try:
                        if os.path.exists(tmp_path):
                            os.remove(tmp_path)
                        shutil.rmtree(tmp_dir, ignore_errors=True)
                    except Exception:
                        pass
            else:
                raise ValueError(f"Unsupported format: {self.fmt}")

            print(tr["worker_enc_used"].format(items=", ".join(sorted(enc_used))), file=log)
            print(tr["worker_delim_used"].format(items=", ".join(sorted(delim_used))), file=log)

            self.q.put(("done", self.out_path, log.getvalue()))
        except PermissionError as e:
            self.q.put(("done", None, tr["worker_perm_error"].format(err=e, details=log.getvalue())))
        except Exception as e:
            self.q.put(("done", None, tr["worker_unexpected"].format(err=e, details=log.getvalue())))

# ----------------------------
# UI
# ----------------------------
class App:
    def __init__(self):
        make_dpi_aware_if_windows()

        self.lang = "en"
        self.root = tk.Tk()
        self.root.title(I18N[self.lang]["title"])
        # Slight scale bump after DPI awareness for consistent sizing
        try:
            self.root.call("tk", "scaling", 1.25)
        except Exception:
            pass

        top = ttk.Frame(self.root, padding=12)
        top.pack(fill="both", expand=True)

        # Controls
        self.btn_select = ttk.Button(top, text="", command=self.on_select_files)
        self.btn_select.grid(row=0, column=0, sticky="w")

        self.lbl_output_format = ttk.Label(top, text="")
        self.lbl_output_format.grid(row=0, column=1, padx=(16, 4), sticky="e")
        self.format_var = tk.StringVar(value="csv")
        self.cmb_format = ttk.Combobox(top, textvariable=self.format_var, values=["csv", "xlsx"], width=6, state="readonly")
        self.cmb_format.grid(row=0, column=2, sticky="w")

        self.btn_output = ttk.Button(top, text="", command=self.on_choose_output, state="disabled")
        self.btn_output.grid(row=0, column=3, padx=(16, 0), sticky="w")

        self.lbl_lang = ttk.Label(top, text="")
        self.lbl_lang.grid(row=0, column=4, padx=(16, 4), sticky="e")
        self.lang_var = tk.StringVar(value=I18N[self.lang]["lang_en"])
        self.cmb_lang = ttk.Combobox(
            top,
            textvariable=self.lang_var,
            values=[I18N["en"]["lang_en"], I18N["en"]["lang_pl"]],
            width=10,
            state="readonly"
        )
        self.cmb_lang.grid(row=0, column=5, sticky="w")

        self.lbl_output = ttk.Label(top, text="")
        self.lbl_output.grid(row=1, column=0, columnspan=6, pady=(6, 12), sticky="w")

        self.pbar = ttk.Progressbar(top, length=420, mode="determinate", maximum=1.0)
        self.pbar.grid(row=2, column=0, columnspan=4, sticky="we")

        self.lbl_status = ttk.Label(top, text="")
        self.lbl_status.grid(row=3, column=0, columnspan=3, sticky="w", pady=(6, 0))

        self.btn_cancel = ttk.Button(top, text="", command=self.on_cancel, state="disabled")
        self.btn_cancel.grid(row=3, column=3, sticky="e", pady=(6, 0))

        self.txt_log = tk.Text(top, width=90, height=20, wrap="none")
        self.txt_log.grid(row=4, column=0, columnspan=4, pady=(8, 0), sticky="nsew")
        self.txt_log.configure(state="disabled")

        top.columnconfigure(0, weight=0)
        top.columnconfigure(1, weight=0)
        top.columnconfigure(2, weight=0)
        top.columnconfigure(3, weight=1)
        top.columnconfigure(4, weight=0)
        top.columnconfigure(5, weight=0)
        top.rowconfigure(4, weight=1)

        # State
        self.paths: List[str] = []
        self.output_path: Optional[str] = None
        self.xlsx_password: Optional[str] = None
        self.cancel_flag = threading.Event()
        self.q: "queue.Queue" = queue.Queue()
        self.worker: Optional[CombineWorker] = None

        self.cmb_format.bind("<<ComboboxSelected>>", lambda _e: self.on_format_changed())
        self.cmb_lang.bind("<<ComboboxSelected>>", self.on_language_changed)
        self.refresh_texts()

    # UI helpers
    def tr(self, key: str, **kwargs) -> str:
        """Translate UI text for current language."""
        template = I18N.get(self.lang, I18N["en"]).get(key, key)
        if kwargs:
            try:
                return template.format(**kwargs)
            except Exception:
                return template
        return template

    def on_language_changed(self, _event=None):
        """Switch UI language between English and Polish."""
        selected = self.lang_var.get()
        self.lang = "pl" if selected == I18N["en"]["lang_pl"] else "en"
        self.refresh_texts()

    def refresh_texts(self):
        """Refresh all translatable UI controls."""
        self.root.title(self.tr("title"))
        self.btn_select.configure(text=self.tr("select_input"))
        self.lbl_output_format.configure(text=self.tr("output_format"))
        self.btn_output.configure(text=self.tr("choose_output"))
        self.lbl_lang.configure(text=self.tr("lang"))
        self.btn_cancel.configure(text=self.tr("cancel"))

        self.cmb_lang.configure(values=[I18N["en"]["lang_en"], I18N["en"]["lang_pl"]])
        self.lang_var.set(I18N["en"]["lang_pl"] if self.lang == "pl" else I18N["en"]["lang_en"])

        if self.lbl_output.cget("text") in {I18N["en"]["no_output"], I18N["pl"]["no_output"], ""}:
            self.lbl_output.configure(text=self.tr("no_output"))
        if self.lbl_status.cget("text") in {
            I18N["en"]["idle"], I18N["pl"]["idle"],
            I18N["en"]["starting_status"], I18N["pl"]["starting_status"],
            I18N["en"]["canceling_status"], I18N["pl"]["canceling_status"],
            I18N["en"]["completed"], I18N["pl"]["completed"],
            I18N["en"]["failed"], I18N["pl"]["failed"],
            ""
        }:
            self.lbl_status.configure(text=self.tr("idle"))

    def append_log(self, msg: str):
        self.txt_log.configure(state="normal")
        self.txt_log.insert("end", msg + "\n")
        self.txt_log.see("end")
        self.txt_log.configure(state="disabled")

    def on_format_changed(self):
        if self.output_path:
            self.output_path = force_extension(self.output_path, self.format_var.get().lower())
            self.lbl_output.config(text=self.output_path)
        if self.format_var.get().lower() == "xlsx":
            self.ask_password()
        else:
            self.xlsx_password = None

    def on_select_files(self):
        paths = filedialog.askopenfilenames(
            title=self.tr("dlg_select_csv"),
            filetypes=[("CSV files", "*.csv"), ("All files", "*.*")]
        )
        self.paths = list(paths)
        if not self.paths:
            messagebox.showerror(self.tr("dlg_no_files_title"), self.tr("dlg_no_files_msg"))
            self.btn_output.configure(state="disabled")
            return

        folder = os.path.dirname(self.paths[0]) or os.getcwd()
        ext = ".csv" if self.format_var.get().lower() == "csv" else ".xlsx"
        self.output_path = os.path.join(folder, f"{DEFAULT_OUTPUT_BASENAME}{ext}")
        self.lbl_output.config(text=self.output_path)
        self.btn_output.configure(state="normal")

        if self.format_var.get().lower() == "xlsx":
            self.ask_password()

        if messagebox.askyesno(self.tr("dlg_ready_title"), self.tr("dlg_ready_msg", count=len(self.paths))):
            self.start_worker()

    def ask_password(self):
        """Ask for optional worksheet edit password for XLSX output."""
        if self.format_var.get().lower() != "xlsx":
            return
        answer = messagebox.askyesno(
            self.tr("dlg_optional_pwd_title"),
            self.tr("dlg_optional_pwd_msg")
        )
        if answer:
            pwd = simpledialog.askstring(self.tr("dlg_pwd_title"), self.tr("dlg_pwd_prompt"), show="*")
            self.xlsx_password = pwd or None
        else:
            self.xlsx_password = None

    def on_choose_output(self):
        fmt = self.format_var.get().lower()
        def_ext = ".xlsx" if fmt == "xlsx" else ".csv"
        def_name = DEFAULT_OUTPUT_BASENAME + def_ext

        path = filedialog.asksaveasfilename(
            title=self.tr("dlg_choose_output"),
            defaultextension=def_ext,
            initialfile=def_name,
            filetypes=[("Excel workbook", "*.xlsx"), ("CSV file", "*.csv"), ("All files", "*.*")]
        )
        if path:
            # Force extension to match the selected format
            self.output_path = force_extension(path, fmt)
            self.lbl_output.config(text=self.output_path)
            if fmt == "xlsx":
                self.ask_password()

    def start_worker(self):
        if not self.paths:
            messagebox.showerror(self.tr("dlg_no_files_title"), self.tr("err_need_input"))
            return
        if not self.output_path:
            messagebox.showerror(self.tr("err_no_output_title"), self.tr("err_need_output"))
            return
        # Enforce extension correctness again before starting
        self.output_path = force_extension(self.output_path, self.format_var.get().lower())
        self.lbl_output.config(text=self.output_path)

        # Reset UI
        self.txt_log.configure(state="normal")
        self.txt_log.delete("1.0", "end")
        self.txt_log.configure(state="disabled")
        self.pbar["value"] = 0.0
        self.lbl_status.config(text=self.tr("starting_status"))
        self.cancel_flag.clear()

        fmt = self.format_var.get().lower()
        self.worker = CombineWorker(self.paths, self.output_path, fmt, self.xlsx_password, self.q, self.cancel_flag, lang=self.lang)
        self.worker.start()

        self.btn_select.configure(state="disabled")
        self.btn_output.configure(state="disabled")
        self.cmb_format.configure(state="disabled")
        self.btn_cancel.configure(state="normal")

        self.root.after(100, self.poll_queue)

    def on_cancel(self):
        self.cancel_flag.set()
        self.lbl_status.config(text=self.tr("canceling_status"))

    def poll_queue(self):
        drained = False
        while True:
            try:
                kind, val, msg = self.q.get_nowait()
                if kind == "progress":
                    self.pbar["value"] = float(val or 0)
                    percent = int(math.floor((float(val or 0)) * 100))
                    self.lbl_status.config(text=self.tr("status_percent", percent=percent, msg=(msg or "")))
                elif kind == "status":
                    self.lbl_status.config(text=str(msg or ""))
                    self.append_log(str(msg or ""))
                elif kind == "done":
                    self.finish(val, msg)
                    drained = True
                    break
            except queue.Empty:
                break

        if not drained and self.worker and self.worker.is_alive():
            self.root.after(100, self.poll_queue)

    def finish(self, out_path: Optional[str], details: str):
        self.btn_select.configure(state="normal")
        self.cmb_format.configure(state="readonly")
        self.btn_output.configure(state="normal")
        self.btn_cancel.configure(state="disabled")

        if out_path:
            self.pbar["value"] = 1.0
            self.lbl_status.config(text=self.tr("completed"))
            self.append_log(details)
            messagebox.showinfo(self.tr("success_title"), self.tr("success_msg", path=out_path))
        else:
            self.lbl_status.config(text=self.tr("failed"))
            self.append_log(details)
            messagebox.showerror(self.tr("fail_title"), details)

    def run(self):
        self.root.mainloop()

# ----------------------------
# Console fallback
# ----------------------------
def run_console():
    print("tkinter not available. Running in console mode.")
    folder = input("Enter folder path with CSV files: ").strip('"').strip()
    if not folder:
        print("No folder provided.")
        return
    paths = [os.path.join(folder, f) for f in os.listdir(folder) if f.lower().endswith(".csv")]
    if not paths:
        print("No CSV files found.")
        return

    fmt = (input("Output format (csv or xlsx) [csv]: ").strip().lower() or "csv")
    if fmt not in {"csv", "xlsx"}:
        print("Unsupported format.")
        return

    out_path = os.path.join(folder, DEFAULT_OUTPUT_BASENAME + (".xlsx" if fmt == "xlsx" else ".csv"))
    out_path = force_extension(out_path, fmt)

    xlsx_password = None
    if fmt == "xlsx":
        ans = input("Protect worksheet with a password to edit? y/N: ").strip().lower()
        if ans == "y":
            xlsx_password = input("Enter password: ").strip() or None

    cancel_flag = threading.Event()
    q: "queue.Queue" = queue.Queue()
    worker = CombineWorker(paths, out_path, fmt, xlsx_password, q, cancel_flag)
    worker.start()

    while True:
        try:
            kind, val, msg = q.get(timeout=0.2)
            if kind == "progress":
                percent = int(float(val or 0) * 100)
                print(f"{percent}% - {msg}")
            elif kind == "status":
                print(msg)
            elif kind == "done":
                if val:
                    print(f"Success. Output: {val}")
                    print(msg)
                else:
                    print("Failed.")
                    print(msg)
                break
        except queue.Empty:
            if not worker.is_alive():
                break

def main() -> int:
    """Run GUI mode when available, otherwise run console fallback."""
    if tk and ttk and filedialog and messagebox:
        App().run()
    else:
        run_console()
    return 0

# ----------------------------
# Entry point
# ----------------------------
if __name__ == "__main__":
    raise SystemExit(main())
