#!/usr/bin/env python3
# /// script
# requires-python = ">=3.9"
# dependencies = [
#   "pandas",
#   "openpyxl",
#   "xlsxwriter",
#   "python-dateutil",
# ]
# ///
"""
Filter and export a combined dataset with low RAM usage.

Key points
- CSV header discovery reads only the first KB, not the full file
- CSV is processed in chunks, filters applied per chunk, results written per chunk
- Excel input still loads through openpyxl (cannot stream reads). Prefer CSV for huge files
- Output formats: CSV or XLSX
- Optional duplicate removal can be done in streaming mode using a rolling hash set on a key subset
"""

import os
import sys
import csv
import json
import zipfile
import shutil
import ctypes
import tempfile
import threading
import queue
import sqlite3
from typing import List, Optional, Tuple, Dict, Iterable
from datetime import datetime

import pandas as pd

EXCEL_MAX_ROWS = 1_048_576
EXCEL_MAX_DATA_ROWS_PER_SHEET = EXCEL_MAX_ROWS - 1
HISTORY_DB_FILE = "script_runner_history.db"

I18N = {
    "en": {
        "app_title": "Filter and export - streaming",
        "actions": "Actions",
        "language": "Language:",
        "pick_input": "Pick input file",
        "no_file_selected": "No file selected",
        "miejsc_list": "Miejscowosc list:",
        "tel_not_empty": "Telefon not empty",
        "text_column": "Text column:",
        "contains": "contains:",
        "numeric_column": "Numeric column:",
        "min": "min:",
        "max": "max:",
        "date_column": "Date column:",
        "from": "from:",
        "to": "to:",
        "trim_spaces": "Trim spaces in text",
        "remove_duplicates": "Remove duplicates",
        "dup_subset": "Dup subset columns (comma separated, optional)",
        "output_format": "Output format:",
        "choose_output": "Choose output",
        "no_output_selected": "No output selected",
        "run_export": "Run and export",
        "reset_filters": "Reset filters",
        "save_preset": "Save preset",
        "load_preset": "Load preset",
        "copy_columns": "Copy columns",
        "clear_log": "Clear log",
        "idle": "Idle",
        "working": "Working...",
        "completed": "Completed",
        "failed": "Failed",
        "start_filtering": "Starting streaming filter",
        "streaming_csv": "Streaming CSV...",
        "excel_input_detected": "Excel input detected. Loading to memory...",
        "detected_columns": "Detected columns: total={total}, numeric={numeric}, date={date}",
        "filters_reset_log": "Filters reset to defaults.",
        "preset_saved_log": "Preset saved: {path}",
        "preset_loaded_log": "Preset loaded: {path}",
        "copied_columns_log": "Copied {count} columns to clipboard.",
        "warn_history_disabled": "Warning: history logging disabled for this run ({err})",
        "warn_history_complete": "Warning: failed to write run completion to history ({err})",
        "warn_history_failed": "Warning: failed to write run failure to history ({err})",
        "title_missing_input": "Missing input",
        "msg_missing_input": "Select an input file first.",
        "title_missing_output": "Missing output",
        "msg_missing_output": "Choose an output location.",
        "title_invalid_filters": "Invalid filter settings",
        "title_success": "Success",
        "title_error": "Error",
        "title_no_columns": "No columns",
        "msg_no_columns": "Load an input file first.",
        "title_header_failed": "Header read failed",
        "title_save_preset": "Save preset",
        "title_load_preset": "Load preset",
        "title_save_preset_failed": "Save preset failed",
        "title_load_preset_failed": "Load preset failed",
        "msg_saved_to": "Saved to: {path}",
        "msg_invalid_preset": "Invalid preset file. Expected a JSON object.",
    },
    "pl": {
        "app_title": "Filtrowanie i eksport - strumieniowo",
        "actions": "Akcje",
        "language": "Jezyk:",
        "pick_input": "Wybierz plik wejsciowy",
        "no_file_selected": "Nie wybrano pliku",
        "miejsc_list": "Lista miejscowosci:",
        "tel_not_empty": "Telefon niepusty",
        "text_column": "Kolumna tekstowa:",
        "contains": "zawiera:",
        "numeric_column": "Kolumna liczbowa:",
        "min": "min:",
        "max": "max:",
        "date_column": "Kolumna daty:",
        "from": "od:",
        "to": "do:",
        "trim_spaces": "Usuwaj spacje w tekscie",
        "remove_duplicates": "Usun duplikaty",
        "dup_subset": "Kolumny klucza duplikatow (po przecinku, opcjonalnie)",
        "output_format": "Format wyjscia:",
        "choose_output": "Wybierz wyjscie",
        "no_output_selected": "Nie wybrano pliku wyjsciowego",
        "run_export": "Uruchom i eksportuj",
        "reset_filters": "Reset filtrów",
        "save_preset": "Zapisz preset",
        "load_preset": "Wczytaj preset",
        "copy_columns": "Kopiuj kolumny",
        "clear_log": "Wyczysc log",
        "idle": "Gotowe",
        "working": "Przetwarzanie...",
        "completed": "Zakonczono",
        "failed": "Blad",
        "start_filtering": "Start filtrowania strumieniowego",
        "streaming_csv": "Strumieniowe przetwarzanie CSV...",
        "excel_input_detected": "Wykryto XLSX. Wczytywanie do pamieci...",
        "detected_columns": "Wykryte kolumny: razem={total}, liczbowe={numeric}, daty={date}",
        "filters_reset_log": "Filtry przywrocono do domyslnych.",
        "preset_saved_log": "Preset zapisany: {path}",
        "preset_loaded_log": "Preset wczytany: {path}",
        "copied_columns_log": "Skopiowano kolumny: {count}.",
        "warn_history_disabled": "Uwaga: historia uruchomien niedostepna ({err})",
        "warn_history_complete": "Uwaga: nie zapisano statusu zakonczenia ({err})",
        "warn_history_failed": "Uwaga: nie zapisano statusu bledu ({err})",
        "title_missing_input": "Brak pliku wejsciowego",
        "msg_missing_input": "Najpierw wybierz plik wejsciowy.",
        "title_missing_output": "Brak pliku wyjsciowego",
        "msg_missing_output": "Najpierw wybierz lokalizacje wyjscia.",
        "title_invalid_filters": "Nieprawidlowe ustawienia filtrow",
        "title_success": "Sukces",
        "title_error": "Blad",
        "title_no_columns": "Brak kolumn",
        "msg_no_columns": "Najpierw wczytaj plik wejsciowy.",
        "title_header_failed": "Blad odczytu naglowka",
        "title_save_preset": "Zapisz preset",
        "title_load_preset": "Wczytaj preset",
        "title_save_preset_failed": "Blad zapisu presetu",
        "title_load_preset_failed": "Blad wczytania presetu",
        "msg_saved_to": "Zapisano do: {path}",
        "msg_invalid_preset": "Nieprawidlowy plik presetu. Oczekiwano obiektu JSON.",
    },
}

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

# ------------- DPI fix -------------
def make_dpi_aware_if_windows() -> None:
    """Enable HiDPI awareness on Windows to improve UI rendering."""
    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

# ------------- Excel helpers -------------
def ensure_xlsx_engine() -> str:
    """Return the preferred Excel writer engine available in the environment."""
    try:
        import xlsxwriter  # noqa
        return "xlsxwriter"
    except Exception:
        pass
    try:
        import openpyxl  # noqa
        return "openpyxl"
    except Exception:
        pass
    raise RuntimeError("No Excel engine available. Install xlsxwriter or openpyxl.")

def validate_xlsx(path: str) -> None:
    """Validate that a file is a non-empty XLSX archive with required workbook parts."""
    if not os.path.exists(path) or os.path.getsize(path) == 0:
        raise RuntimeError("Output file is missing or empty.")
    if not zipfile.is_zipfile(path):
        raise RuntimeError("Output 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)}")

def force_extension(path: str, fmt: str) -> str:
    """Return a path with extension coerced to the selected output format."""
    base, _ = os.path.splitext(path)
    return base + (".xlsx" if fmt.lower() == "xlsx" else ".csv")

def replace_output_file(src: str, dst: str) -> None:
    """
    Replace dst with src.
    Falls back to copy+replace when src and dst are on 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 resolve_and_validate_config_columns(cfg: Dict, cols: List[str]) -> Dict:
    """
    Resolve configured column names case-insensitively against available columns.
    Returns a copy of cfg with resolved canonical column names.
    """
    out = dict(cfg)
    col_map = {c.strip().lower(): c for c in cols}
    errors = []

    for key, label in [("text_col", "Text column"), ("num_col", "Numeric column"), ("date_col", "Date column")]:
        raw = (out.get(key) or "").strip()
        if not raw:
            out[key] = None
            continue
        resolved = col_map.get(raw.lower())
        if resolved is None:
            errors.append(f"{label}: '{raw}'")
        else:
            out[key] = resolved

    resolved_dups: List[str] = []
    if out.get("remove_dups"):
        seen = set()
        for raw in out.get("dup_cols", []) or []:
            name = (raw or "").strip()
            if not name:
                continue
            resolved = col_map.get(name.lower())
            if resolved is None:
                errors.append(f"Duplicate subset column: '{name}'")
                continue
            if resolved not in seen:
                seen.add(resolved)
                resolved_dups.append(resolved)
    out["dup_cols"] = resolved_dups

    if errors:
        raise ValueError("Unknown column(s): " + ", ".join(errors))
    return out

def sheet_name_for_index(base_name: str, index: int) -> str:
    """Build a worksheet name for multi-sheet exports."""
    if index <= 1:
        return base_name
    return f"{base_name}_{index}"

def append_chunk_to_excel(writer, chunk: pd.DataFrame, state: Dict) -> None:
    """
    Append chunk to an Excel writer and split to multiple sheets when row limit is reached.
    state keys:
      - base_name
      - sheet_index
      - rows_in_sheet
      - sheet_rows (dict sheet_name -> data rows in that sheet)
    """
    if chunk.empty:
        return

    offset = 0
    while offset < len(chunk):
        if state["rows_in_sheet"] >= EXCEL_MAX_DATA_ROWS_PER_SHEET:
            state["sheet_index"] += 1
            state["rows_in_sheet"] = 0

        sheet_name = sheet_name_for_index(state["base_name"], state["sheet_index"])
        room = EXCEL_MAX_DATA_ROWS_PER_SHEET - state["rows_in_sheet"]
        take = min(room, len(chunk) - offset)
        part = chunk.iloc[offset: offset + take]

        header = state["rows_in_sheet"] == 0
        startrow = 0 if header else state["rows_in_sheet"] + 1
        part.to_excel(
            writer,
            index=False,
            header=header,
            sheet_name=sheet_name,
            startrow=startrow
        )

        state["rows_in_sheet"] += len(part)
        state["sheet_rows"][sheet_name] = state["rows_in_sheet"]
        offset += take

def apply_xlsxwriter_sheet_formatting(writer, cols: List[str], sheet_rows: Dict[str, int]) -> None:
    """Apply header autofilter and frozen pane formatting to written XLSX sheets."""
    if writer.engine != "xlsxwriter":
        return
    if not cols:
        return
    last_col = max(0, len(cols) - 1)
    for sheet_name, data_rows in sheet_rows.items():
        ws = writer.sheets.get(sheet_name)
        if ws is None:
            continue
        ws.autofilter(0, 0, max(0, data_rows), last_col)
        ws.freeze_panes(1, 0)

def infer_numeric_and_date_columns(df_sample: pd.DataFrame) -> Tuple[List[str], List[str]]:
    """Infer likely numeric and date columns from a sampled dataframe."""
    numeric_cols = [c for c in df_sample.columns if pd.api.types.is_numeric_dtype(df_sample[c])]
    date_cols: List[str] = []

    for c in df_sample.columns:
        s = df_sample[c]
        non_na = s.dropna()
        if non_na.empty:
            continue

        if pd.api.types.is_datetime64_any_dtype(s):
            date_cols.append(c)
            continue

        # Pure numeric columns often represent IDs (e.g. NIP), not dates.
        if pd.api.types.is_numeric_dtype(s):
            continue

        text = non_na.astype("string").str.strip()
        text = text[text != ""]
        if text.empty:
            continue

        name_hint = any(k in str(c).lower() for k in ["data", "date", "czas", "termin"])
        date_like = text.str.contains(r"\d{1,4}[./-]\d{1,2}[./-]\d{1,4}", regex=True, na=False)
        date_like_ratio = float(date_like.mean()) if len(text) else 0.0

        parsed = pd.to_datetime(text, errors="coerce", dayfirst=True)
        parsed_count = int(parsed.notna().sum())
        parsed_ratio = parsed_count / len(text)

        if name_hint:
            if parsed_count >= 1 and parsed_ratio >= 0.1:
                date_cols.append(c)
            continue

        if date_like_ratio >= 0.3 and parsed_count >= max(3, int(len(text) * 0.2)) and parsed_ratio >= 0.2:
            date_cols.append(c)

    return numeric_cols, date_cols

def split_csv_values(raw: str) -> List[str]:
    """Split a comma-separated string into a trimmed list of non-empty values."""
    return [v.strip() for v in (raw or "").split(",") if v.strip()]

def parse_optional_float(raw, field_name: str) -> Optional[float]:
    """Parse an optional numeric input and return float or None."""
    if raw is None:
        return None
    if isinstance(raw, (int, float)):
        return float(raw)
    value = str(raw).strip()
    if not value:
        return None
    try:
        return float(value.replace(",", "."))
    except ValueError:
        raise ValueError(f"{field_name} must be a number. Got '{value}'.")

def parse_optional_date(raw, field_name: str):
    """Parse an optional date input and return Timestamp or None."""
    if raw is None or raw == "":
        return None
    if isinstance(raw, pd.Timestamp):
        return raw
    value = str(raw).strip()
    if not value:
        return None
    try:
        return pd.to_datetime(value, dayfirst=True, errors="raise")
    except Exception:
        raise ValueError(f"{field_name} must be a valid date, e.g. 31-12-2025. Got '{value}'.")

def first_match(options: List[str], keywords: List[str]) -> Optional[str]:
    """Return the first option containing any keyword, or first option as fallback."""
    lowered = {o.lower(): o for o in options}
    for kw in keywords:
        for k, original in lowered.items():
            if kw in k:
                return original
    return options[0] if options else None

def build_filter_config(raw: Dict) -> Dict:
    """Build validated filter configuration from raw form-like values."""
    num_min = parse_optional_float(raw.get("num_min"), "Numeric min")
    num_max = parse_optional_float(raw.get("num_max"), "Numeric max")
    if num_min is not None and num_max is not None and num_min > num_max:
        raise ValueError("Numeric min cannot be greater than numeric max.")

    date_from = parse_optional_date(raw.get("date_from"), "Date from")
    date_to = parse_optional_date(raw.get("date_to"), "Date to")
    if date_from is not None and date_to is not None and date_from > date_to:
        raise ValueError("Date from cannot be later than date to.")

    return {
        "miejsc_values": split_csv_values(raw.get("miejsc_values", "")),
        "telefon_not_empty": bool(raw.get("telefon_not_empty", False)),
        "text_col": (raw.get("text_col") or "").strip() or None,
        "text_contains": (raw.get("text_contains") or "").strip(),
        "num_col": (raw.get("num_col") or "").strip() or None,
        "num_min": num_min,
        "num_max": num_max,
        "date_col": (raw.get("date_col") or "").strip() or None,
        "date_from_parsed": date_from,
        "date_to_parsed": date_to,
        "trim_text": bool(raw.get("trim_text", True)),
        "remove_dups": bool(raw.get("remove_dups", False)),
        "dup_cols": split_csv_values(raw.get("dup_cols", "")),
    }

# ------------- Run history -------------
def get_history_db_path() -> str:
    """Return absolute path to the GUI run history SQLite database."""
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), HISTORY_DB_FILE)

def ensure_history_schema(db_path: Optional[str] = None) -> None:
    """Create run-history table and indexes if they do not exist."""
    path = db_path or get_history_db_path()
    with sqlite3.connect(path) as conn:
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS gui_runs (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                started_at TEXT NOT NULL,
                finished_at TEXT,
                status TEXT NOT NULL,
                input_path TEXT,
                output_path TEXT,
                output_format TEXT,
                config_json TEXT,
                message TEXT
            )
            """
        )
        conn.execute("CREATE INDEX IF NOT EXISTS idx_gui_runs_started_at ON gui_runs(started_at)")
        conn.execute("CREATE INDEX IF NOT EXISTS idx_gui_runs_status ON gui_runs(status)")
        conn.commit()

def create_run_history_entry(
    input_path: str,
    output_path: str,
    output_format: str,
    cfg: Dict,
    db_path: Optional[str] = None,
) -> int:
    """Insert a started run record and return its row id."""
    ensure_history_schema(db_path=db_path)
    path = db_path or get_history_db_path()
    started_at = datetime.utcnow().isoformat(timespec="seconds") + "Z"
    payload = json.dumps(cfg, ensure_ascii=False, default=str)
    with sqlite3.connect(path) as conn:
        cur = conn.execute(
            """
            INSERT INTO gui_runs (
                started_at, status, input_path, output_path, output_format, config_json, message
            ) VALUES (?, ?, ?, ?, ?, ?, ?)
            """,
            (started_at, "running", input_path, output_path, output_format, payload, "Started"),
        )
        conn.commit()
        return int(cur.lastrowid)

def finalize_run_history_entry(
    run_id: int,
    status: str,
    message: str,
    db_path: Optional[str] = None,
) -> None:
    """Mark a run record as completed or failed with finish timestamp."""
    path = db_path or get_history_db_path()
    finished_at = datetime.utcnow().isoformat(timespec="seconds") + "Z"
    with sqlite3.connect(path) as conn:
        conn.execute(
            """
            UPDATE gui_runs
            SET finished_at = ?, status = ?, message = ?
            WHERE id = ?
            """,
            (finished_at, status, message, run_id),
        )
        conn.commit()

# ------------- CSV header discovery -------------
def sniff_delimiter(sample_text: str) -> Optional[str]:
    """Detect CSV delimiter using sniffer with a lightweight fallback heuristic."""
    try:
        dialect = csv.Sniffer().sniff(sample_text, delimiters=[",", ";", "\t", "|"])
        return dialect.delimiter
    except Exception:
        pass
    counts = {d: sample_text.count(d) for d in [",", ";", "\t", "|"]}
    best = max(counts, key=counts.get)
    return best if counts[best] > 0 else None

def read_csv_header_fast(path: str, encodings: Iterable[str]) -> Tuple[List[str], str, str]:
    """
    Read only the header line from a CSV.
    Returns: columns, chosen_encoding, chosen_delimiter
    """
    last_err = None
    # read small prefix once
    with open(path, "rb") as f:
        raw = f.read(64 * 1024)
    for enc in encodings:
        try:
            txt = raw.decode(enc, errors="strict")
        except Exception as e:
            last_err = e
            continue
        delim = sniff_delimiter(txt) or ","
        # parse header only
        first_line = txt.splitlines()[0]
        reader = csv.reader([first_line], delimiter=delim)
        cols = next(reader)
        cols = [c.replace("\ufeff", "").strip() for c in cols]
        return cols, enc, delim
    raise RuntimeError(f"Could not decode header. Last error: {last_err}")

# ------------- Streaming filters -------------
def build_chunk_filter(cfg: Dict, cols: List[str]):
    """Create and return a vectorized dataframe filter function for chunk processing."""

    # name resolution with tolerance for case
    col_map = {c.lower(): c for c in cols}
    col_miej = col_map.get("miejscowosc")
    col_tel = col_map.get("telefon")

    miejsc_values = [v.strip().lower() for v in cfg["miejsc_values"] if v.strip()]
    text_col = cfg["text_col"]
    text_contains = (cfg["text_contains"] or "").lower()
    num_col = cfg["num_col"]
    num_min = cfg["num_min"]
    num_max = cfg["num_max"]
    date_col = cfg["date_col"]
    date_from = cfg.get("date_from_parsed")
    date_to = cfg.get("date_to_parsed")
    trim_text = cfg["trim_text"]
    dedup_subset = [c.strip() for c in cfg.get("dup_cols", []) if c.strip()]

    trim_target_cols = set()
    if miejsc_values and col_miej:
        trim_target_cols.add(col_miej)
    if cfg["telefon_not_empty"] and col_tel:
        trim_target_cols.add(col_tel)
    if text_col and text_contains:
        trim_target_cols.add(text_col)
    if date_col and (date_from is not None or date_to is not None):
        trim_target_cols.add(date_col)
    trim_target_cols.update(dedup_subset)

    def f(df: pd.DataFrame) -> pd.Series:
        """Compute a boolean mask for rows that satisfy configured filters."""
        out_mask = pd.Series(True, index=df.index)

        # optional trim
        if trim_text:
            obj_cols = set(df.select_dtypes(include=["object", "string"]).columns)
            if cfg["remove_dups"] and not dedup_subset:
                cols_to_trim = list(obj_cols)
            else:
                cols_to_trim = [c for c in trim_target_cols if c in obj_cols]
            for c in cols_to_trim:
                df[c] = df[c].astype("string").str.replace("\uFEFF", "", regex=False).str.strip()

        if miejsc_values and col_miej and col_miej in df.columns:
            s = df[col_miej].astype("string").str.strip().str.lower()
            out_mask &= s.isin(miejsc_values)

        if cfg["telefon_not_empty"] and col_tel and col_tel in df.columns:
            s = df[col_tel].astype("string").str.strip()
            out_mask &= s.notna() & (s != "")

        if text_col and text_col in df.columns and text_contains:
            s = df[text_col].astype("string").str.lower()
            out_mask &= s.str.contains(text_contains, na=False)

        if num_col and num_col in df.columns and (num_min is not None or num_max is not None):
            s = pd.to_numeric(df[num_col], errors="coerce")
            if num_min is not None:
                out_mask &= s >= num_min
            if num_max is not None:
                out_mask &= s <= num_max

        if date_col and date_col in df.columns and (date_from is not None or date_to is not None):
            s = pd.to_datetime(df[date_col], errors="coerce", dayfirst=True)
            if date_from is not None:
                out_mask &= s >= date_from
            if date_to is not None:
                out_mask &= s <= date_to

        return out_mask

    return f

# ------------- Streaming CSV read and write -------------
def stream_filter_csv(
    in_path: str,
    out_path: str,
    out_fmt: str,
    cfg: Dict,
    qput
):
    """
    Read a large CSV in chunks, filter, and write out as CSV or XLSX without loading everything into RAM.
    """

    encodings = ["utf-8", "utf-8-sig", "cp1250", "cp852", "latin-1"]
    cols, chosen_enc, delim = read_csv_header_fast(in_path, encodings)
    cfg = resolve_and_validate_config_columns(cfg, cols)
    chunk_filter = build_chunk_filter(cfg, cols)

    # duplicate handling across chunks using a rolling set of keys
    dedup_subset = [c.strip() for c in cfg.get("dup_cols", []) if c.strip()]
    seen_keys = set()

    # stream read
    total_in = 0
    total_out = 0
    chunk_rows = 200_000  # tune for speed vs memory

    read_kwargs = dict(
        sep=delim,
        encoding=chosen_enc,
        encoding_errors="replace",
        dtype=None
    )

    engine = "c"
    try:
        pd.read_csv(in_path, engine="c", nrows=2000, **read_kwargs)
    except Exception as e:
        engine = "python"
        qput(f"Fast CSV parser unavailable ({e}). Falling back to python parser.")

    if out_fmt == "csv":
        tmp_csv = out_path + ".tmp"
        first_write = True

        for chunk in pd.read_csv(in_path, engine=engine, chunksize=chunk_rows, **read_kwargs):
            total_in += len(chunk)

            mask = chunk_filter(chunk)
            out_chunk = chunk[mask]

            # streaming de-dup if requested
            if cfg["remove_dups"]:
                if dedup_subset:
                    key_df = out_chunk[dedup_subset].astype("string")
                    keys = list(map(tuple, key_df.to_numpy()))
                else:
                    keys = list(map(tuple, out_chunk.astype("string").to_numpy()))
                keep_mask = []
                for k in keys:
                    if k in seen_keys:
                        keep_mask.append(False)
                    else:
                        seen_keys.add(k)
                        keep_mask.append(True)
                out_chunk = out_chunk[pd.Series(keep_mask, index=out_chunk.index)]

            total_out += len(out_chunk)

            if len(out_chunk):
                if first_write:
                    out_chunk.to_csv(tmp_csv, index=False, encoding="utf-8-sig", quoting=csv.QUOTE_MINIMAL)
                    first_write = False
                else:
                    out_chunk.to_csv(tmp_csv, index=False, header=False, mode="a", encoding="utf-8-sig", quoting=csv.QUOTE_MINIMAL)

            qput(f"Processed {total_in:,} rows, kept {total_out:,}")

        if first_write:
            # Export must still succeed even when all rows are filtered out.
            pd.DataFrame(columns=cols).to_csv(tmp_csv, index=False, encoding="utf-8-sig", quoting=csv.QUOTE_MINIMAL)
        replace_output_file(tmp_csv, out_path)
        return

    xlsx_engine = ensure_xlsx_engine()
    with tempfile.TemporaryDirectory(prefix="stream_xlsx_") as tmp_dir:
        tmp_xlsx = os.path.join(tmp_dir, "out.xlsx")
        with pd.ExcelWriter(tmp_xlsx, engine=xlsx_engine) as writer:
            sheet_state = {
                "base_name": "Filtered",
                "sheet_index": 1,
                "rows_in_sheet": 0,
                "sheet_rows": {}
            }

            for chunk in pd.read_csv(in_path, engine=engine, chunksize=chunk_rows, **read_kwargs):
                total_in += len(chunk)

                mask = chunk_filter(chunk)
                out_chunk = chunk[mask]

                if cfg["remove_dups"]:
                    if dedup_subset:
                        key_df = out_chunk[dedup_subset].astype("string")
                        keys = list(map(tuple, key_df.to_numpy()))
                    else:
                        keys = list(map(tuple, out_chunk.astype("string").to_numpy()))
                    keep_mask = []
                    for k in keys:
                        if k in seen_keys:
                            keep_mask.append(False)
                        else:
                            seen_keys.add(k)
                            keep_mask.append(True)
                    out_chunk = out_chunk[pd.Series(keep_mask, index=out_chunk.index)]

                total_out += len(out_chunk)

                if len(out_chunk):
                    append_chunk_to_excel(writer, out_chunk, sheet_state)

                qput(f"Processed {total_in:,} rows, kept {total_out:,}")

            if total_out == 0:
                pd.DataFrame(columns=cols).to_excel(writer, index=False, sheet_name="Filtered")
                sheet_state["sheet_rows"]["Filtered"] = 0

            apply_xlsxwriter_sheet_formatting(writer, cols, sheet_state["sheet_rows"])

        validate_xlsx(tmp_xlsx)
        replace_output_file(tmp_xlsx, out_path)

# ------------- Excel input path -------------
def filter_excel_input(
    in_path: str,
    out_path: str,
    out_fmt: str,
    cfg: Dict,
    qput
):
    """
    For Excel input we must read into memory. Filters are still applied efficiently.
    For huge files, convert to CSV first and use the streaming CSV path.
    """
    df = pd.read_excel(in_path, dtype=None)
    cols = list(df.columns)
    cfg = resolve_and_validate_config_columns(cfg, cols)
    chunk_filter = build_chunk_filter(cfg, cols)
    mask = chunk_filter(df)
    out_df = df[mask]

    if cfg["remove_dups"]:
        subset = [c.strip() for c in cfg.get("dup_cols", []) if c.strip()] or None
        out_df = out_df.drop_duplicates(subset=subset, keep="first")

    if out_fmt == "csv":
        tmp = out_path + ".tmp"
        out_df.to_csv(tmp, index=False, encoding="utf-8-sig", quoting=csv.QUOTE_MINIMAL)
        replace_output_file(tmp, out_path)
    else:
        engine = ensure_xlsx_engine()
        with tempfile.TemporaryDirectory(prefix="xlsx_out_") as tmp_dir:
            tmp_xlsx = os.path.join(tmp_dir, "out.xlsx")
            with pd.ExcelWriter(tmp_xlsx, engine=engine) as writer:
                sheet_rows: Dict[str, int] = {}

                if len(out_df) == 0:
                    pd.DataFrame(columns=cols).to_excel(writer, index=False, sheet_name="Filtered")
                    sheet_rows["Filtered"] = 0
                else:
                    start = 0
                    sheet_index = 1
                    while start < len(out_df):
                        end = min(start + EXCEL_MAX_DATA_ROWS_PER_SHEET, len(out_df))
                        sheet_name = sheet_name_for_index("Filtered", sheet_index)
                        part = out_df.iloc[start:end]
                        part.to_excel(writer, index=False, sheet_name=sheet_name)
                        sheet_rows[sheet_name] = len(part)
                        start = end
                        sheet_index += 1

                apply_xlsxwriter_sheet_formatting(writer, cols, sheet_rows)

            validate_xlsx(tmp_xlsx)
            replace_output_file(tmp_xlsx, out_path)

# ------------- Worker thread -------------
class FilterWorker(threading.Thread):
    def __init__(self, in_path: str, out_path: str, out_fmt: str, cfg: Dict, q):
        """Initialize worker thread state for async filtering/export."""
        super().__init__(daemon=True)
        self.in_path = in_path
        self.out_path = out_path
        self.out_fmt = out_fmt
        self.cfg = cfg
        self.q = q

    def run(self):
        """Execute filtering in a background thread and report progress via queue."""
        try:
            lang = self.cfg.get("_lang", "en")
            tr = I18N.get(lang, I18N["en"])
            ext = os.path.splitext(self.in_path)[1].lower()
            if ext == ".csv":
                self.q.put(("status", tr["streaming_csv"]))
                stream_filter_csv(self.in_path, self.out_path, self.out_fmt, self.cfg, lambda m: self.q.put(("status", m)))
            else:
                self.q.put(("status", tr["excel_input_detected"]))
                filter_excel_input(self.in_path, self.out_path, self.out_fmt, self.cfg, lambda m: self.q.put(("status", m)))
            self.q.put(("done", tr["msg_saved_to"].format(path=self.out_path)))
        except Exception as e:
            self.q.put(("error", f"{tr['failed']}: {e}"))

# ------------- UI helpers -------------
class ToolTip:
    def __init__(self, widget, text: str, delay_ms: int = 350):
        """Attach a delayed hover/focus tooltip to a widget."""
        self.widget = widget
        self.text = text
        self.delay_ms = delay_ms
        self.tipwindow = None
        self._after_id = None

        widget.bind("<Enter>", self._schedule_show, add="+")
        widget.bind("<Leave>", self._hide, add="+")
        widget.bind("<FocusIn>", self._schedule_show, add="+")
        widget.bind("<FocusOut>", self._hide, add="+")

    def _schedule_show(self, _event=None):
        """Schedule tooltip display after the configured delay."""
        self._cancel()
        self._after_id = self.widget.after(self.delay_ms, self._show)

    def _cancel(self):
        """Cancel any pending tooltip show callback."""
        if self._after_id is not None:
            try:
                self.widget.after_cancel(self._after_id)
            except Exception:
                pass
            self._after_id = None

    def _show(self):
        """Create and display the tooltip window."""
        self._after_id = None
        if self.tipwindow is not None:
            return
        if not self.text:
            return

        x = self.widget.winfo_rootx() + 12
        y = self.widget.winfo_rooty() + self.widget.winfo_height() + 8
        tw = tk.Toplevel(self.widget)
        tw.wm_overrideredirect(True)
        tw.wm_geometry(f"+{x}+{y}")
        lbl = tk.Label(
            tw,
            text=self.text,
            justify="left",
            background="#fff9d9",
            relief="solid",
            borderwidth=1,
            padx=8,
            pady=4,
            wraplength=420,
        )
        lbl.pack()
        self.tipwindow = tw

    def _hide(self, _event=None):
        """Hide and destroy the tooltip window if visible."""
        self._cancel()
        if self.tipwindow is not None:
            try:
                self.tipwindow.destroy()
            except Exception:
                pass
            self.tipwindow = None

# ------------- GUI -------------
class App:
    def __init__(self):
        """Build the GUI, initialize state, and wire interactions."""
        make_dpi_aware_if_windows()
        self.root = tk.Tk()
        self.root.title("Filter and export - streaming")
        self.root.geometry("1220x760")
        self.root.minsize(1020, 660)

        try:
            self.root.call("tk", "scaling", 1.25)
        except Exception:
            pass

        self.in_path: Optional[str] = None
        self.headers: List[str] = []
        self.numeric_cols: List[str] = []
        self.date_cols: List[str] = []
        self.out_path: Optional[str] = None
        self.lang = "en"
        self.lang_var = tk.StringVar(value="English")
        self.current_run_id: Optional[int] = None
        self._tooltips: List[ToolTip] = []
        self._tooltip_map: Dict = {}
        try:
            ensure_history_schema()
        except Exception:
            # Do not block UI startup if history DB cannot be initialized.
            pass

        main = ttk.Frame(self.root, padding=12)
        main.pack(fill="both", expand=True)
        main.columnconfigure(0, weight=0)
        main.columnconfigure(1, weight=1)
        main.columnconfigure(2, weight=0)
        main.columnconfigure(3, weight=1)
        main.columnconfigure(4, weight=0)
        main.columnconfigure(5, weight=1)
        main.columnconfigure(6, weight=0)
        main.rowconfigure(9, weight=1)
        self.actions = ttk.LabelFrame(main, text="Actions", padding=(8, 6))
        self.actions.grid(row=8, column=0, columnspan=6, sticky="we", pady=(6, 0))

        # Input row
        self.btn_in = ttk.Button(main, text="Pick input file", command=self.pick_input)
        self.lbl_in = ttk.Label(main, text="No file selected", foreground="#334155")
        self.lbl_in.grid(row=0, column=0, columnspan=6, sticky="we")

        # Filters
        self.lbl_miejsc = ttk.Label(main, text="Miejscowosc list:")
        self.lbl_miejsc.grid(row=1, column=0, sticky="e", pady=(8, 0))
        self.ent_miejsc = ttk.Entry(main, width=50)
        self.ent_miejsc.grid(row=1, column=1, columnspan=3, sticky="we", pady=(8, 0))
        self.btn_in.grid(row=1, column=4, sticky="e", padx=(8, 8), pady=(8, 0))

        self.var_tel = tk.BooleanVar(value=True)
        self.chk_tel = ttk.Checkbutton(main, text="Telefon not empty", variable=self.var_tel)
        self.chk_tel.grid(row=1, column=5, sticky="w", pady=(8, 0))

        self.lbl_text_col = ttk.Label(main, text="Text column:")
        self.lbl_text_col.grid(row=2, column=0, sticky="e", pady=(8, 0))
        self.cmb_text_col = ttk.Combobox(main, values=[], state="readonly", width=28)
        self.cmb_text_col.grid(row=2, column=1, sticky="w", pady=(8, 0))
        self.lbl_contains = ttk.Label(main, text="contains:")
        self.lbl_contains.grid(row=2, column=2, sticky="e", pady=(8, 0))
        self.ent_text = ttk.Entry(main, width=28)
        self.ent_text.insert(0, "Aktywny")
        self.ent_text.grid(row=2, column=3, sticky="w", pady=(8, 0))

        self.lbl_num_col = ttk.Label(main, text="Numeric column:")
        self.lbl_num_col.grid(row=3, column=0, sticky="e", pady=(8, 0))
        self.cmb_num_col = ttk.Combobox(main, values=[], state="readonly", width=28)
        self.cmb_num_col.grid(row=3, column=1, sticky="w", pady=(8, 0))
        self.lbl_num_min = ttk.Label(main, text="min:")
        self.lbl_num_min.grid(row=3, column=2, sticky="e", pady=(8, 0))
        self.ent_num_min = ttk.Entry(main, width=12)
        self.ent_num_min.grid(row=3, column=3, sticky="w", pady=(8, 0))
        self.lbl_num_max = ttk.Label(main, text="max:")
        self.lbl_num_max.grid(row=3, column=4, sticky="e", pady=(8, 0))
        self.ent_num_max = ttk.Entry(main, width=12)
        self.ent_num_max.grid(row=3, column=5, sticky="w", pady=(8, 0))

        self.lbl_date_col = ttk.Label(main, text="Date column:")
        self.lbl_date_col.grid(row=4, column=0, sticky="e", pady=(8, 0))
        self.cmb_date_col = ttk.Combobox(main, values=[], state="readonly", width=28)
        self.cmb_date_col.grid(row=4, column=1, sticky="w", pady=(8, 0))
        self.lbl_date_from = ttk.Label(main, text="from:")
        self.lbl_date_from.grid(row=4, column=2, sticky="e", pady=(8, 0))
        self.ent_date_from = ttk.Entry(main, width=12)
        self.ent_date_from.grid(row=4, column=3, sticky="w", pady=(8, 0))
        self.lbl_date_to = ttk.Label(main, text="to:")
        self.lbl_date_to.grid(row=4, column=4, sticky="e", pady=(8, 0))
        self.ent_date_to = ttk.Entry(main, width=12)
        self.ent_date_to.grid(row=4, column=5, sticky="w", pady=(8, 0))

        self.var_trim = tk.BooleanVar(value=True)
        self.chk_trim = ttk.Checkbutton(main, text="Trim spaces in text", variable=self.var_trim)
        self.chk_trim.grid(row=5, column=0, sticky="w", pady=(8, 0))

        self.var_dups = tk.BooleanVar(value=False)
        self.chk_dups = ttk.Checkbutton(main, text="Remove duplicates", variable=self.var_dups)
        self.chk_dups.grid(row=5, column=1, sticky="w", pady=(8, 0))
        self.lbl_dup_subset = ttk.Label(main, text="Dup subset columns (comma separated, optional)")
        self.lbl_dup_subset.grid(row=5, column=2, columnspan=2, sticky="e", pady=(8, 0))
        self.ent_dup_cols = ttk.Entry(main, width=30)
        self.ent_dup_cols.grid(row=5, column=4, columnspan=2, sticky="we", pady=(8, 0))

        self.lbl_output_fmt = ttk.Label(main, text="Output format:")
        self.lbl_output_fmt.grid(row=6, column=0, sticky="e", pady=(12, 0))
        self.var_fmt = tk.StringVar(value="xlsx")
        self.cmb_fmt = ttk.Combobox(main, textvariable=self.var_fmt, values=["xlsx", "csv"], width=6, state="readonly")
        self.cmb_fmt.grid(row=6, column=1, sticky="w", pady=(12, 0))
        self.btn_out = ttk.Button(self.actions, text="Choose output", command=self.choose_output, state="disabled")
        self.lbl_out = ttk.Label(main, text="No output selected")
        self.lbl_out.grid(row=6, column=3, columnspan=3, sticky="we", pady=(12, 0))

        self.style = ttk.Style(self.root)
        self.style.configure("Run.TButton", padding=(16, 10))
        self.btn_run = ttk.Button(self.actions, text="Run and export", command=self.run_filters, state="disabled", style="Run.TButton", width=18)
        self.lbl_status = ttk.Label(main, text="Idle")
        self.lbl_status.grid(row=7, column=0, columnspan=2, sticky="w", pady=(12, 0))
        self.progress = ttk.Progressbar(main, mode="indeterminate", length=220)
        self.progress.grid(row=7, column=3, columnspan=3, sticky="e", pady=(12, 0))

        self.btn_reset = ttk.Button(self.actions, text="Reset filters", command=self.reset_filters, state="normal")
        self.btn_save_preset = ttk.Button(self.actions, text="Save preset", command=self.save_preset, state="normal")
        self.btn_load_preset = ttk.Button(self.actions, text="Load preset", command=self.load_preset, state="normal")
        self.btn_copy_cols = ttk.Button(self.actions, text="Copy columns", command=self.copy_columns_to_clipboard, state="disabled")
        self.btn_clear_log = ttk.Button(self.actions, text="Clear log", command=self.clear_log, state="normal")

        self.lbl_lang = ttk.Label(self.actions, text="Language:")
        self.lbl_lang.grid(row=0, column=0, sticky="w", padx=(0, 4))
        self.cmb_lang = ttk.Combobox(
            self.actions,
            textvariable=self.lang_var,
            values=["English", "Polski"],
            width=10,
            state="readonly"
        )
        self.cmb_lang.grid(row=0, column=1, sticky="w", padx=(0, 12))
        self.cmb_lang.bind("<<ComboboxSelected>>", self.on_language_change)

        self.btn_out.grid(row=0, column=2, sticky="w", padx=(0, 8))
        self.btn_run.grid(row=0, column=3, sticky="w", padx=(0, 12))
        self.btn_reset.grid(row=0, column=4, sticky="w", padx=(0, 8))
        self.btn_save_preset.grid(row=0, column=5, sticky="w", padx=(0, 8))
        self.btn_load_preset.grid(row=0, column=6, sticky="w", padx=(0, 8))
        self.btn_copy_cols.grid(row=0, column=7, sticky="w", padx=(0, 8))
        self.btn_clear_log.grid(row=0, column=8, sticky="w")

        self.txt_log = tk.Text(main, width=100, height=14, wrap="none")
        self.txt_log.grid(row=9, column=0, columnspan=6, sticky="nsew", pady=(8, 0))
        self.txt_log.configure(state="disabled")
        self.scr_y = ttk.Scrollbar(main, orient="vertical", command=self.txt_log.yview)
        self.scr_y.grid(row=9, column=6, sticky="nsw")
        self.scr_x = ttk.Scrollbar(main, orient="horizontal", command=self.txt_log.xview)
        self.scr_x.grid(row=10, column=0, columnspan=6, sticky="ew")
        self.txt_log.configure(yscrollcommand=self.scr_y.set, xscrollcommand=self.scr_x.set)

        self.root.bind("<Control-o>", lambda _e: self.pick_input())
        self.root.bind("<Control-s>", lambda _e: self.choose_output())
        self.root.bind("<Control-Return>", lambda _e: self.run_filters())
        self.root.bind("<F5>", lambda _e: self.run_filters())

        self.q = queue.Queue()
        self.worker: Optional[FilterWorker] = None
        self._attach_tooltips()
        self.refresh_texts()

    def tr(self, key: str, **kwargs) -> str:
        """Translate a UI key for the active 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_change(self, _event=None):
        """Handle language selector changes."""
        selected = self.lang_var.get()
        self.lang = "pl" if selected.lower().startswith("pol") else "en"
        self.refresh_texts()

    def refresh_texts(self):
        """Refresh all translatable texts in the UI."""
        self.root.title(self.tr("app_title"))
        self.actions.configure(text=self.tr("actions"))
        self.lbl_lang.configure(text=self.tr("language"))
        self.btn_in.configure(text=self.tr("pick_input"))
        self.lbl_miejsc.configure(text=self.tr("miejsc_list"))
        self.chk_tel.configure(text=self.tr("tel_not_empty"))
        self.lbl_text_col.configure(text=self.tr("text_column"))
        self.lbl_contains.configure(text=self.tr("contains"))
        self.lbl_num_col.configure(text=self.tr("numeric_column"))
        self.lbl_num_min.configure(text=self.tr("min"))
        self.lbl_num_max.configure(text=self.tr("max"))
        self.lbl_date_col.configure(text=self.tr("date_column"))
        self.lbl_date_from.configure(text=self.tr("from"))
        self.lbl_date_to.configure(text=self.tr("to"))
        self.chk_trim.configure(text=self.tr("trim_spaces"))
        self.chk_dups.configure(text=self.tr("remove_duplicates"))
        self.lbl_dup_subset.configure(text=self.tr("dup_subset"))
        self.lbl_output_fmt.configure(text=self.tr("output_format"))
        self.btn_out.configure(text=self.tr("choose_output"))
        self.btn_run.configure(text=self.tr("run_export"))
        self.btn_reset.configure(text=self.tr("reset_filters"))
        self.btn_save_preset.configure(text=self.tr("save_preset"))
        self.btn_load_preset.configure(text=self.tr("load_preset"))
        self.btn_copy_cols.configure(text=self.tr("copy_columns"))
        self.btn_clear_log.configure(text=self.tr("clear_log"))

        if self.lbl_in.cget("text") in {I18N["en"]["no_file_selected"], I18N["pl"]["no_file_selected"]}:
            self.lbl_in.configure(text=self.tr("no_file_selected"))
        if self.lbl_out.cget("text") in {I18N["en"]["no_output_selected"], I18N["pl"]["no_output_selected"]}:
            self.lbl_out.configure(text=self.tr("no_output_selected"))
        if self.lbl_status.cget("text") in {I18N["en"]["idle"], I18N["pl"]["idle"]}:
            self.lbl_status.configure(text=self.tr("idle"))

        self._attach_tooltips()

    def _add_tooltip(self, widget, text: str):
        """Register tooltip text for a widget."""
        tip = self._tooltip_map.get(widget)
        if tip is None:
            tip = ToolTip(widget, text)
            self._tooltips.append(tip)
            self._tooltip_map[widget] = tip
        else:
            tip.text = text

    def _attach_tooltips(self):
        """Attach descriptive tooltips to all interactive controls."""
        tt = {
            "en": {
                "btn_in": "Select input dataset (.csv or .xlsx). Shortcut: Ctrl+O.",
                "lbl_in": "Current input file path.",
                "ent_miejsc": "Comma-separated miejscowosc values to keep. Leave empty to skip this filter.",
                "chk_tel": "Keep rows only where Telefon is not empty.",
                "cmb_text_col": "Column used for the text 'contains' filter.",
                "ent_text": "Case-insensitive text match value. Leave empty to disable.",
                "cmb_num_col": "Column used for numeric min/max filtering.",
                "ent_num_min": "Minimum numeric value (inclusive).",
                "ent_num_max": "Maximum numeric value (inclusive).",
                "cmb_date_col": "Column used for date range filtering.",
                "ent_date_from": "Start date (inclusive), e.g. 01-01-2025.",
                "ent_date_to": "End date (inclusive), e.g. 31-12-2025.",
                "chk_trim": "Trim BOM/whitespace in relevant text columns before filtering.",
                "chk_dups": "Remove duplicate rows in output.",
                "ent_dup_cols": "Optional comma-separated columns to define duplicate keys. Empty means full-row duplicates.",
                "cmb_fmt": "Output format for export.",
                "btn_out": "Choose output file path. Shortcut: Ctrl+S.",
                "lbl_out": "Current output file path.",
                "btn_run": "Run filters and export output. Shortcuts: F5 or Ctrl+Enter.",
                "btn_reset": "Reset filter inputs to default values.",
                "btn_save_preset": "Save current filter settings to a JSON preset.",
                "btn_load_preset": "Load filter settings from a JSON preset.",
                "btn_copy_cols": "Copy detected input column names to clipboard.",
                "btn_clear_log": "Clear the log panel.",
                "lbl_status": "Current worker status.",
                "txt_log": "Execution log and progress details.",
                "cmb_lang": "Switch application language.",
            },
            "pl": {
                "btn_in": "Wybierz plik wejsciowy (.csv lub .xlsx). Skrot: Ctrl+O.",
                "lbl_in": "Aktualna sciezka pliku wejsciowego.",
                "ent_miejsc": "Lista miejscowosci oddzielona przecinkami. Puste pole = brak filtra.",
                "chk_tel": "Zachowaj tylko rekordy z niepustym telefonem.",
                "cmb_text_col": "Kolumna dla filtra tekstowego 'zawiera'.",
                "ent_text": "Wartosc tekstu (bez rozrozniania wielkosci liter).",
                "cmb_num_col": "Kolumna dla zakresu liczbowego.",
                "ent_num_min": "Minimalna wartosc (wlacznie).",
                "ent_num_max": "Maksymalna wartosc (wlacznie).",
                "cmb_date_col": "Kolumna dla zakresu dat.",
                "ent_date_from": "Data poczatkowa (wlacznie), np. 01-01-2025.",
                "ent_date_to": "Data koncowa (wlacznie), np. 31-12-2025.",
                "chk_trim": "Usuwaj BOM/spacje z kolumn tekstowych przed filtrowaniem.",
                "chk_dups": "Usuwaj duplikaty w wyniku.",
                "ent_dup_cols": "Opcjonalne kolumny klucza duplikatow (po przecinku). Puste = caly wiersz.",
                "cmb_fmt": "Format pliku wynikowego.",
                "btn_out": "Wybierz plik wyjsciowy. Skrot: Ctrl+S.",
                "lbl_out": "Aktualna sciezka pliku wyjsciowego.",
                "btn_run": "Uruchom filtrowanie i eksport. Skroty: F5 lub Ctrl+Enter.",
                "btn_reset": "Przywroc domyslne ustawienia filtrow.",
                "btn_save_preset": "Zapisz biezace ustawienia do pliku JSON.",
                "btn_load_preset": "Wczytaj ustawienia z pliku JSON.",
                "btn_copy_cols": "Skopiuj wykryte nazwy kolumn do schowka.",
                "btn_clear_log": "Wyczysc panel logu.",
                "lbl_status": "Biezacy status pracy.",
                "txt_log": "Log operacji i postepu.",
                "cmb_lang": "Przelacz jezyk aplikacji.",
            }
        }
        lang_tt = tt.get(self.lang, tt["en"])
        self._add_tooltip(self.btn_in, lang_tt["btn_in"])
        self._add_tooltip(self.lbl_in, lang_tt["lbl_in"])
        self._add_tooltip(self.ent_miejsc, lang_tt["ent_miejsc"])
        self._add_tooltip(self.chk_tel, lang_tt["chk_tel"])
        self._add_tooltip(self.cmb_text_col, lang_tt["cmb_text_col"])
        self._add_tooltip(self.ent_text, lang_tt["ent_text"])
        self._add_tooltip(self.cmb_num_col, lang_tt["cmb_num_col"])
        self._add_tooltip(self.ent_num_min, lang_tt["ent_num_min"])
        self._add_tooltip(self.ent_num_max, lang_tt["ent_num_max"])
        self._add_tooltip(self.cmb_date_col, lang_tt["cmb_date_col"])
        self._add_tooltip(self.ent_date_from, lang_tt["ent_date_from"])
        self._add_tooltip(self.ent_date_to, lang_tt["ent_date_to"])
        self._add_tooltip(self.chk_trim, lang_tt["chk_trim"])
        self._add_tooltip(self.chk_dups, lang_tt["chk_dups"])
        self._add_tooltip(self.ent_dup_cols, lang_tt["ent_dup_cols"])
        self._add_tooltip(self.cmb_fmt, lang_tt["cmb_fmt"])
        self._add_tooltip(self.btn_out, lang_tt["btn_out"])
        self._add_tooltip(self.lbl_out, lang_tt["lbl_out"])
        self._add_tooltip(self.btn_run, lang_tt["btn_run"])
        self._add_tooltip(self.btn_reset, lang_tt["btn_reset"])
        self._add_tooltip(self.btn_save_preset, lang_tt["btn_save_preset"])
        self._add_tooltip(self.btn_load_preset, lang_tt["btn_load_preset"])
        self._add_tooltip(self.btn_copy_cols, lang_tt["btn_copy_cols"])
        self._add_tooltip(self.btn_clear_log, lang_tt["btn_clear_log"])
        self._add_tooltip(self.lbl_status, lang_tt["lbl_status"])
        self._add_tooltip(self.txt_log, lang_tt["txt_log"])
        self._add_tooltip(self.cmb_lang, lang_tt["cmb_lang"])

    def set_busy(self, busy: bool):
        """Toggle busy mode and enable/disable controls accordingly."""
        run_state = "disabled" if busy else ("normal" if self.in_path and self.out_path else "disabled")
        out_state = "disabled" if busy else ("normal" if self.in_path else "disabled")
        input_state = "disabled" if busy else "normal"
        entry_state = "disabled" if busy else "normal"

        self.btn_in.configure(state=input_state)
        self.btn_out.configure(state=out_state)
        self.btn_run.configure(state=run_state)
        self.btn_reset.configure(state=input_state)
        self.btn_save_preset.configure(state=input_state)
        self.btn_load_preset.configure(state=input_state)
        self.btn_clear_log.configure(state=input_state)
        self.btn_copy_cols.configure(state="disabled" if busy or not self.headers else "normal")

        for w in [self.ent_miejsc, self.ent_text, self.ent_num_min, self.ent_num_max, self.ent_date_from, self.ent_date_to, self.ent_dup_cols]:
            w.configure(state=entry_state)
        for w in [self.chk_tel, self.chk_trim, self.chk_dups]:
            w.configure(state=input_state)
        for w in [self.cmb_text_col, self.cmb_num_col, self.cmb_date_col, self.cmb_fmt]:
            w.configure(state="disabled" if busy else "readonly")

        if busy:
            self.progress.start(10)
        else:
            self.progress.stop()

    def log(self, text: str):
        """Append a message to the log pane and keep it scrolled to the end."""
        self.txt_log.configure(state="normal")
        self.txt_log.insert("end", text + "\n")
        self.txt_log.see("end")
        self.txt_log.configure(state="disabled")

    def clear_log(self):
        """Clear all text from the GUI log pane."""
        self.txt_log.configure(state="normal")
        self.txt_log.delete("1.0", "end")
        self.txt_log.configure(state="disabled")

    def get_form_values(self) -> Dict:
        """Return the current form state as a serializable dictionary."""
        return {
            "miejsc_values": self.ent_miejsc.get(),
            "telefon_not_empty": self.var_tel.get(),
            "text_col": self.cmb_text_col.get(),
            "text_contains": self.ent_text.get(),
            "num_col": self.cmb_num_col.get(),
            "num_min": self.ent_num_min.get(),
            "num_max": self.ent_num_max.get(),
            "date_col": self.cmb_date_col.get(),
            "date_from": self.ent_date_from.get(),
            "date_to": self.ent_date_to.get(),
            "trim_text": self.var_trim.get(),
            "remove_dups": self.var_dups.get(),
            "dup_cols": self.ent_dup_cols.get(),
            "out_fmt": self.var_fmt.get(),
        }

    def apply_form_values(self, values: Dict):
        """Populate form controls from a preset-like dictionary."""
        self.ent_miejsc.delete(0, "end")
        self.ent_miejsc.insert(0, values.get("miejsc_values", ""))
        self.var_tel.set(bool(values.get("telefon_not_empty", True)))

        self.cmb_text_col.set(values.get("text_col", "") or "")
        self.ent_text.delete(0, "end")
        self.ent_text.insert(0, values.get("text_contains", ""))

        self.cmb_num_col.set(values.get("num_col", "") or "")
        self.ent_num_min.delete(0, "end")
        self.ent_num_min.insert(0, values.get("num_min", "") or "")
        self.ent_num_max.delete(0, "end")
        self.ent_num_max.insert(0, values.get("num_max", "") or "")

        self.cmb_date_col.set(values.get("date_col", "") or "")
        self.ent_date_from.delete(0, "end")
        self.ent_date_from.insert(0, values.get("date_from", "") or "")
        self.ent_date_to.delete(0, "end")
        self.ent_date_to.insert(0, values.get("date_to", "") or "")

        self.var_trim.set(bool(values.get("trim_text", True)))
        self.var_dups.set(bool(values.get("remove_dups", False)))
        self.ent_dup_cols.delete(0, "end")
        self.ent_dup_cols.insert(0, values.get("dup_cols", "") or "")

        fmt = str(values.get("out_fmt", "xlsx")).lower()
        self.var_fmt.set("csv" if fmt == "csv" else "xlsx")

    def reset_filters(self):
        """Reset filter controls to default values."""
        self.apply_form_values(
            {
                "miejsc_values": "",
                "telefon_not_empty": True,
                "text_col": "",
                "text_contains": "Aktywny",
                "num_col": "",
                "num_min": "",
                "num_max": "",
                "date_col": "",
                "date_from": "",
                "date_to": "",
                "trim_text": True,
                "remove_dups": False,
                "dup_cols": "",
                "out_fmt": self.var_fmt.get() or "xlsx",
            }
        )
        self.log(self.tr("filters_reset_log"))

    def save_preset(self):
        """Save the current filter form state to a JSON preset file."""
        try:
            preset = self.get_form_values()
            path = filedialog.asksaveasfilename(
                title=self.tr("title_save_preset"),
                defaultextension=".json",
                initialfile="filter_preset.json",
                filetypes=[("JSON file", "*.json"), ("All files", "*.*")]
            )
            if not path:
                return
            with open(path, "w", encoding="utf-8") as f:
                json.dump(preset, f, ensure_ascii=False, indent=2)
            self.log(self.tr("preset_saved_log", path=path))
        except Exception as e:
            messagebox.showerror(self.tr("title_save_preset_failed"), str(e))

    def load_preset(self):
        """Load filter form state from a JSON preset file."""
        try:
            path = filedialog.askopenfilename(
                title=self.tr("title_load_preset"),
                filetypes=[("JSON file", "*.json"), ("All files", "*.*")]
            )
            if not path:
                return
            with open(path, "r", encoding="utf-8") as f:
                data = json.load(f)
            if not isinstance(data, dict):
                raise ValueError(self.tr("msg_invalid_preset"))
            self.apply_form_values(data)
            self.log(self.tr("preset_loaded_log", path=path))
        except Exception as e:
            messagebox.showerror(self.tr("title_load_preset_failed"), str(e))

    def copy_columns_to_clipboard(self):
        """Copy detected input column names to the system clipboard."""
        if not self.headers:
            messagebox.showwarning(self.tr("title_no_columns"), self.tr("msg_no_columns"))
            return
        text = "\n".join(self.headers)
        self.root.clipboard_clear()
        self.root.clipboard_append(text)
        self.root.update_idletasks()
        self.log(self.tr("copied_columns_log", count=len(self.headers)))

    def pick_input(self):
        """Select input file, infer columns, and prepare default control values."""
        path = filedialog.askopenfilename(
            title=self.tr("pick_input"),
            filetypes=[("CSV or Excel", "*.csv *.xlsx"), ("CSV", "*.csv"), ("Excel", "*.xlsx"), ("All files", "*.*")]
        )
        if not path:
            return
        self.in_path = path
        self.lbl_in.config(text=path)

        ext = os.path.splitext(path)[1].lower()
        chosen_enc = None
        delim = None
        if ext == ".csv":
            try:
                cols, chosen_enc, delim = read_csv_header_fast(path, ["utf-8", "utf-8-sig", "cp1250", "cp852", "latin-1"])
            except Exception as e:
                messagebox.showerror(self.tr("title_header_failed"), str(e))
                return
            self.headers = cols
        else:
            # read only header rows using pandas
            df_head = pd.read_excel(path, nrows=0)
            self.headers = list(df_head.columns)

        # simple type guesses for convenience
        df_sample = None
        try:
            if ext == ".csv":
                sample_kwargs = dict(
                    sep=delim or ",",
                    encoding=chosen_enc or "utf-8",
                    encoding_errors="replace"
                )
                try:
                    df_sample = pd.read_csv(path, nrows=5000, engine="c", **sample_kwargs)
                except Exception:
                    df_sample = pd.read_csv(path, nrows=5000, engine="python", **sample_kwargs)
            else:
                df_sample = pd.read_excel(path, nrows=2000)
        except Exception:
            df_sample = None

        numeric_cols = []
        date_cols = []
        if df_sample is not None:
            numeric_cols, date_cols = infer_numeric_and_date_columns(df_sample)
        self.numeric_cols = numeric_cols
        self.date_cols = date_cols

        self.cmb_text_col["values"] = self.headers
        self.cmb_num_col["values"] = numeric_cols
        self.cmb_date_col["values"] = date_cols
        if not self.cmb_text_col.get():
            default_text = first_match(self.headers, ["status", "akty", "stan", "nazwa"])
            if default_text:
                self.cmb_text_col.set(default_text)
        if not self.cmb_num_col.get():
            default_num = first_match(numeric_cols, ["rok", "lp", "nr", "ilosc", "kwota"])
            if default_num:
                self.cmb_num_col.set(default_num)
        if not self.cmb_date_col.get():
            default_date = first_match(date_cols, ["data", "date", "czas", "termin"])
            if default_date:
                self.cmb_date_col.set(default_date)

        folder = os.path.dirname(path) or os.getcwd()
        self.out_path = os.path.join(folder, "filtered.csv")
        self.lbl_out.config(text=self.out_path)
        self.btn_out.configure(state="normal")
        self.btn_run.configure(state="normal")
        self.btn_copy_cols.configure(state="normal")
        self.set_busy(False)
        self.log(self.tr("detected_columns", total=len(self.headers), numeric=len(numeric_cols), date=len(date_cols)))

    def choose_output(self):
        """Select and normalize output file path based on chosen output format."""
        fmt = self.var_fmt.get()
        def_ext = ".xlsx" if fmt == "xlsx" else ".csv"
        path = filedialog.asksaveasfilename(
            title=self.tr("choose_output"),
            defaultextension=def_ext,
            initialfile=("filtered" + def_ext),
            filetypes=[("Excel workbook", "*.xlsx"), ("CSV file", "*.csv"), ("All files", "*.*")]
        )
        if path:
            self.out_path = force_extension(path, fmt)
            self.lbl_out.config(text=self.out_path)

    def run_filters(self):
        """Validate form inputs and start background filtering/export."""
        if not self.in_path:
            messagebox.showerror(self.tr("title_missing_input"), self.tr("msg_missing_input"))
            return
        if not self.out_path:
            messagebox.showerror(self.tr("title_missing_output"), self.tr("msg_missing_output"))
            return

        try:
            cfg = build_filter_config(
                {
                    "miejsc_values": self.ent_miejsc.get(),
                    "telefon_not_empty": self.var_tel.get(),
                    "text_col": self.cmb_text_col.get(),
                    "text_contains": self.ent_text.get(),
                    "num_col": self.cmb_num_col.get(),
                    "num_min": self.ent_num_min.get(),
                    "num_max": self.ent_num_max.get(),
                    "date_col": self.cmb_date_col.get(),
                    "date_from": self.ent_date_from.get(),
                    "date_to": self.ent_date_to.get(),
                    "trim_text": self.var_trim.get(),
                    "remove_dups": self.var_dups.get(),
                    "dup_cols": self.ent_dup_cols.get(),
                }
            )
            cfg = resolve_and_validate_config_columns(cfg, self.headers)
        except ValueError as e:
            messagebox.showerror(self.tr("title_invalid_filters"), str(e))
            return

        out_fmt = self.var_fmt.get().lower()
        self.out_path = force_extension(self.out_path, out_fmt)
        self.lbl_out.config(text=self.out_path)
        cfg["_lang"] = self.lang

        try:
            self.current_run_id = create_run_history_entry(
                input_path=self.in_path,
                output_path=self.out_path,
                output_format=out_fmt,
                cfg=cfg,
            )
        except Exception as e:
            self.current_run_id = None
            self.log(self.tr("warn_history_disabled", err=e))

        self.set_busy(True)
        self.lbl_status.config(text=self.tr("working"))
        self.log(self.tr("start_filtering"))

        self.q = queue.Queue()
        self.worker = FilterWorker(self.in_path, self.out_path, out_fmt, cfg, self.q)
        self.worker.start()
        self.root.after(100, self.poll)

    def poll(self):
        """Poll worker queue for status/progress/completion events."""
        try:
            while True:
                kind, payload = self.q.get_nowait()
                if kind == "status":
                    self.lbl_status.config(text=str(payload))
                    self.log(str(payload))
                elif kind == "done":
                    self.lbl_status.config(text=self.tr("completed"))
                    self.log(payload)
                    if self.current_run_id is not None:
                        try:
                            finalize_run_history_entry(self.current_run_id, "completed", str(payload))
                        except Exception as e:
                            self.log(self.tr("warn_history_complete", err=e))
                        finally:
                            self.current_run_id = None
                    messagebox.showinfo(self.tr("title_success"), payload)
                    self.set_busy(False)
                    return
                elif kind == "error":
                    self.lbl_status.config(text=self.tr("failed"))
                    self.log(str(payload))
                    if self.current_run_id is not None:
                        try:
                            finalize_run_history_entry(self.current_run_id, "failed", str(payload))
                        except Exception as e:
                            self.log(self.tr("warn_history_failed", err=e))
                        finally:
                            self.current_run_id = None
                    messagebox.showerror(self.tr("title_error"), str(payload))
                    self.set_busy(False)
                    return
        except queue.Empty:
            self.root.after(100, self.poll)

    def run(self):
        """Start the Tk main event loop."""
        self.root.mainloop()

# ------------- Console fallback -------------
def run_console():
    """Run a console fallback flow when tkinter is unavailable."""
    print("tkinter not available, running in console mode.")
    in_path = input("Input file path: ").strip('"').strip()
    fmt = input("Output format csv or xlsx [csv]: ").strip().lower() or "csv"
    out_path = force_extension(input("Output path [filtered.csv]: ").strip() or "filtered.csv", fmt)

    miejsc_values = [v.strip() for v in input("Miejscowosc list (comma separated): ").split(",") if v.strip()]
    tel_not_empty = input("Telefon not empty? y/N: ").strip().lower() == "y"
    text_col = input("Text column: ").strip() or None
    text_contains = input("Text contains: ").strip()
    num_col = input("Numeric column: ").strip() or None
    num_min = input("Min: ").strip()
    num_max = input("Max: ").strip()
    date_col = input("Date column: ").strip() or None
    date_from = input("Date from dd-mm-yyyy: ").strip()
    date_to = input("Date to dd-mm-yyyy: ").strip()
    remove_dups = input("Remove duplicates? y/N: ").strip().lower() == "y"
    dup_cols = [c.strip() for c in input("Dup subset columns comma separated: ").split(",") if c.strip()]

    cfg = build_filter_config(
        {
            "miejsc_values": ",".join(miejsc_values),
            "telefon_not_empty": tel_not_empty,
            "text_col": text_col,
            "text_contains": text_contains,
            "num_col": num_col,
            "num_min": num_min,
            "num_max": num_max,
            "date_col": date_col,
            "date_from": date_from,
            "date_to": date_to,
            "trim_text": True,
            "remove_dups": remove_dups,
            "dup_cols": ",".join(dup_cols),
        }
    )

    ext = os.path.splitext(in_path)[1].lower()
    if ext == ".csv":
        stream_filter_csv(in_path, out_path, fmt, cfg, print)
    else:
        filter_excel_input(in_path, out_path, fmt, cfg, print)
    print(f"Saved to: {out_path}")

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 -------------
if __name__ == "__main__":
    raise SystemExit(main())
