"""
SubtitleBurner — Auto-transcribe, translate to English, burn subtitles into video.
Uses faster-whisper for GPU/CPU transcription and ffmpeg for subtitle burning.
"""

import os
import sys
import time
import json
import shutil
import subprocess
import threading
import tempfile
import platform
import re
import textwrap
from pathlib import Path

import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext

# ──────────────────────────────────────────────
# ASS Style Configuration with Dynamic Scaling
# ──────────────────────────────────────────────

def get_video_dimensions(video_path):
    """Get video width and height using ffprobe."""
    try:
        cmd = [
            "ffprobe", "-v", "error",
            "-select_streams", "v:0",
            "-show_entries", "stream=width,height",
            "-of", "csv=p=0",
            str(video_path)
        ]
        result = subprocess.run(cmd, capture_output=True, text=True, check=True)
        width, height = map(int, result.stdout.strip().split(','))
        return width, height
    except Exception as e:
        print(f"Error getting video dimensions: {e}")
        return 1920, 1080

def calculate_scaled_font_size(video_height, reference_height=1080, reference_font_size=42):
    """Calculate font size proportional to video height."""
    scale_factor = video_height / reference_height
    scaled_size = int(reference_font_size * scale_factor)
    return max(16, min(scaled_size, 42))

def calculate_scaled_shadow(video_height, reference_height=1080, reference_shadow=2):
    """Calculate shadow proportional to video height."""
    scale_factor = video_height / reference_height
    scaled_shadow = max(1, int(reference_shadow * scale_factor))
    return scaled_shadow

def calculate_scaled_outline(video_height, reference_height=1080, reference_outline=1):
    """Calculate outline proportional to video height."""
    scale_factor = video_height / reference_height
    scaled_outline = max(0, int(reference_outline * scale_factor))
    return scaled_outline

def calculate_scaled_margins(video_height, reference_height=1080, reference_margin=10):
    """Calculate margins proportional to video height."""
    scale_factor = video_height / reference_height
    scaled_margin = max(5, int(reference_margin * scale_factor))
    return scaled_margin

def hex_to_ass_color(hex_color):
    """Convert hex color to ASS format (BGR with alpha)."""
    hex_color = hex_color.lstrip('#')
    r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
    return f"&H00{b:02x}{g:02x}{r:02x}".upper()

def wrap_text_by_chars(text, chars_per_line=35, max_lines=2):
    """Wrap text by character count."""
    text = text.strip()
    if not text:
        return text
    
    words = text.split()
    lines = []
    current_line = ""
    
    for word in words:
        if len(current_line) + len(word) + (1 if current_line else 0) <= chars_per_line:
            if current_line:
                current_line += " " + word
            else:
                current_line = word
        else:
            if current_line:
                lines.append(current_line)
            current_line = word
            
            if len(word) > chars_per_line:
                chunks = [word[i:i+chars_per_line] for i in range(0, len(word), chars_per_line)]
                lines.extend(chunks[:-1])
                current_line = chunks[-1]
    
    if current_line:
        lines.append(current_line)
    
    if len(lines) > max_lines:
        full_text = ' '.join(words)
        total_chars = len(full_text)
        
        if max_lines == 1:
            lines = [full_text]
        elif max_lines == 2:
            mid = total_chars // 2
            split_pos = full_text.rfind(' ', 0, mid)
            if split_pos == -1:
                split_pos = full_text.find(' ', mid)
            if split_pos != -1:
                lines = [full_text[:split_pos], full_text[split_pos+1:]]
            else:
                lines = [full_text[:mid], full_text[mid:]]
        else:
            chunk_size = total_chars // 3
            lines = []
            remaining = full_text
            for i in range(3):
                if i == 2:
                    lines.append(remaining)
                    break
                split_pos = chunk_size
                if split_pos < len(remaining):
                    space_pos = remaining.rfind(' ', 0, split_pos)
                    if space_pos == -1:
                        space_pos = remaining.find(' ', split_pos)
                    if space_pos != -1:
                        split_pos = space_pos
                lines.append(remaining[:split_pos])
                remaining = remaining[split_pos+1:].lstrip()
    
    if len(lines) > max_lines:
        merged = ' '.join(lines[max_lines-1:])
        lines = lines[:max_lines-1] + [merged]
    
    return '\n'.join(lines)

def wrap_segment_text_advanced(segments, chars_per_line, max_lines, video_width, reference_width=1920):
    """Adjust text wrapping based on user settings."""
    wrapped_segments = []
    for start, end, text in segments:
        wrapped_text = wrap_text_by_chars(text, chars_per_line, max_lines)
        wrapped_segments.append((start, end, wrapped_text))
    return wrapped_segments

def generate_ass_header(video_width, video_height, angle=135, y_offset=0):
    """Generate ASS header with dynamically scaled styles."""
    font_size = calculate_scaled_font_size(video_height)
    shadow = calculate_scaled_shadow(video_height)
    outline = calculate_scaled_outline(video_height)
    margin = calculate_scaled_margins(video_height)
    gold_color = hex_to_ass_color("#FEBD00")
    
    base_margin = max(5, int(video_height * 0.03))
    adjusted_margin = max(5, base_margin + y_offset)
    
    if video_height < 500:
        shadow = max(1, shadow)
        outline = 0
        adjusted_margin = max(5, adjusted_margin)
    
    return f"""[Script Info]
Title: SubtitleBurner Generated Subtitles
ScriptType: v4.00+
Collisions: Normal
PlayDepth: 0
Timer: 100.0000
VideoWidth: {video_width}
VideoHeight: {video_height}
WrapStyle: 2

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,{font_size},{gold_color},&H00FFFFFF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,{outline},{shadow},2,{margin},{margin},{adjusted_margin},1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
"""

def seconds_to_ass_time(seconds):
    """Convert seconds to ASS time format."""
    hours = int(seconds // 3600)
    minutes = int((seconds % 3600) // 60)
    secs = seconds % 60
    centiseconds = int((secs - int(secs)) * 100)
    return f"{hours}:{minutes:02d}:{int(secs):02d}.{centiseconds:02d}"

def segments_to_ass(segments, video_width, video_height, angle=135, chars_per_line=35, max_lines=2, y_offset=0):
    """Convert segments to ASS format."""
    wrapped_segments = wrap_segment_text_advanced(segments, chars_per_line, max_lines, video_width)
    header = generate_ass_header(video_width, video_height, angle, y_offset)
    lines = [header]
    
    for start, end, text in wrapped_segments:
        start_time = seconds_to_ass_time(start)
        end_time = seconds_to_ass_time(end)
        text_lines = text.split('\n')
        ass_text = '\\N'.join(text_lines)
        styled_text = f"{{\\an2\\ang{angle}}}{ass_text}"
        lines.append(f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{styled_text}")
    
    return "\n".join(lines)


# ──────────────────────────────────────────────
# Resource detection
# ──────────────────────────────────────────────

def detect_resources():
    """Detect GPU, CUDA, RAM and pick the best whisper device/compute_type."""
    info = {
        "platform": platform.system(),
        "cpu_cores": os.cpu_count(),
        "ram_gb": None,
        "gpu": None,
        "cuda": False,
        "device": "cpu",
        "compute_type": "int8",
        "model_size": "medium",
    }

    try:
        import psutil
        info["ram_gb"] = round(psutil.virtual_memory().total / 1e9, 1)
    except ImportError:
        pass

    try:
        import torch
        if torch.cuda.is_available():
            info["cuda"] = True
            info["gpu"] = torch.cuda.get_device_name(0)
            vram = torch.cuda.get_device_properties(0).total_memory / 1e9
            info["vram_gb"] = round(vram, 1)
            if vram >= 8:
                info["device"] = "cuda"
                info["compute_type"] = "float16"
                info["model_size"] = "large-v3"
            elif vram >= 4:
                info["device"] = "cuda"
                info["compute_type"] = "float16"
                info["model_size"] = "medium"
            else:
                info["device"] = "cuda"
                info["compute_type"] = "int8"
                info["model_size"] = "small"
        elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
            info["gpu"] = "Apple Silicon MPS"
            info["device"] = "cpu"
            info["compute_type"] = "int8"
            info["model_size"] = "medium"
    except ImportError:
        pass

    if info["device"] == "cpu":
        ram = info.get("ram_gb") or 4
        if ram >= 16:
            info["model_size"] = "medium"
        elif ram >= 8:
            info["model_size"] = "small"
        else:
            info["model_size"] = "base"

    return info

def check_ffmpeg():
    """Return True if ffmpeg is available."""
    return shutil.which("ffmpeg") is not None

def check_ffprobe():
    """Return True if ffprobe is available."""
    return shutil.which("ffprobe") is not None


# ──────────────────────────────────────────────
# Core processing
# ──────────────────────────────────────────────

def transcribe_video(video_path, device, compute_type, model_size, translate, progress_cb, log_cb):
    """Run faster-whisper on the video."""
    from faster_whisper import WhisperModel

    log_cb(f"Loading Whisper model '{model_size}' on {device} ({compute_type})…")
    model = WhisperModel(model_size, device=device, compute_type=compute_type)

    task = "translate" if translate else "transcribe"
    log_cb(f"Starting {task}… (this may take a while)")

    segments_iter, info = model.transcribe(
        video_path,
        task=task,
        beam_size=5,
        vad_filter=True,
        word_timestamps=False,
    )

    log_cb(f"Detected language: {info.language} (confidence {info.language_probability:.0%})")

    segments = []
    for seg in segments_iter:
        segments.append((seg.start, seg.end, seg.text.strip()))
        progress_cb(min(95, int(seg.end / info.duration * 80) + 5) if info.duration else 50)
        log_cb(f"  [{seg.start:.1f}s → {seg.end:.1f}s] {seg.text.strip()}")

    return segments, info.language

def burn_subtitles_ass(video_path, ass_path, output_path, progress_cb, log_cb):
    """Use ffmpeg to burn ASS/SSA subtitles."""
    log_cb("Burning ASS subtitles into video with ffmpeg…")
    ass_escaped = str(ass_path).replace("\\", "/").replace(":", "\\:")

    cmd = [
        "ffmpeg", "-y",
        "-i", str(video_path),
        "-vf", f"ass='{ass_escaped}'",
        "-c:a", "copy",
        "-preset", "fast",
        str(output_path),
    ]

    process = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        universal_newlines=True,
    )

    duration_sec = None
    for line in process.stdout:
        line = line.strip()
        if "Duration:" in line and duration_sec is None:
            try:
                dur_str = line.split("Duration:")[1].split(",")[0].strip()
                h, m, s = dur_str.split(":")
                duration_sec = int(h) * 3600 + int(m) * 60 + float(s)
            except Exception:
                pass
        if "time=" in line and duration_sec:
            try:
                time_str = line.split("time=")[1].split(" ")[0]
                h, m, s = time_str.split(":")
                current = int(h) * 3600 + int(m) * 60 + float(s)
                pct = int(95 + (current / duration_sec) * 4)
                progress_cb(min(99, pct))
            except Exception:
                pass

    process.wait()
    if process.returncode != 0:
        raise RuntimeError("ffmpeg failed — check the log.")
    log_cb("✓ Subtitle burning complete.")


# ──────────────────────────────────────────────
# GUI
# ──────────────────────────────────────────────

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("SubtitleBurner")
        self.resizable(True, True)
        self.minsize(800, 650)

        self.resources = None
        self.processing = False
        self.video_dimensions = None

        self._build_ui()
        self._detect_resources_async()

    def _build_ui(self):
        self.configure(bg="#FAFAF8")
        style = ttk.Style(self)
        style.theme_use("clam")
        style.configure("TFrame", background="#FAFAF8")
        style.configure("TLabel", background="#FAFAF8", font=("Segoe UI", 10))
        style.configure("TButton", font=("Segoe UI", 10), padding=6)
        style.configure("Accent.TButton", font=("Segoe UI", 10, "bold"), padding=6)
        style.configure("TCheckbutton", background="#FAFAF8", font=("Segoe UI", 10))
        style.configure("TLabelframe", background="#FAFAF8", font=("Segoe UI", 10, "bold"))
        style.configure("TLabelframe.Label", background="#FAFAF8", font=("Segoe UI", 10, "bold"))
        style.configure("TProgressbar", thickness=8)

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

        # Header
        hdr = ttk.Frame(root)
        hdr.pack(fill="x", pady=(0, 12))
        ttk.Label(hdr, text="SubtitleBurner", font=("Segoe UI", 18, "bold")).pack(side="left")
        ttk.Label(hdr, text="Auto-transcribe · Translate · Burn", foreground="#888", font=("Segoe UI", 10)).pack(side="left", padx=12, pady=4)

        # Resources panel
        res_frame = ttk.LabelFrame(root, text="Detected resources", padding=8)
        res_frame.pack(fill="x", pady=(0, 10))
        self.res_label = ttk.Label(res_frame, text="Detecting…", foreground="#666")
        self.res_label.pack(anchor="w")

        # Input file
        inp_frame = ttk.LabelFrame(root, text="Input video", padding=8)
        inp_frame.pack(fill="x", pady=(0, 10))
        row = ttk.Frame(inp_frame)
        row.pack(fill="x")
        self.input_var = tk.StringVar()
        ttk.Entry(row, textvariable=self.input_var, font=("Segoe UI", 10)).pack(side="left", fill="x", expand=True)
        ttk.Button(row, text="Browse…", command=self._browse_input).pack(side="left", padx=(6, 0))

        # Output file
        out_frame = ttk.LabelFrame(root, text="Output video", padding=8)
        out_frame.pack(fill="x", pady=(0, 10))
        row2 = ttk.Frame(out_frame)
        row2.pack(fill="x")
        self.output_var = tk.StringVar()
        ttk.Entry(row2, textvariable=self.output_var, font=("Segoe UI", 10)).pack(side="left", fill="x", expand=True)
        ttk.Button(row2, text="Browse…", command=self._browse_output).pack(side="left", padx=(6, 0))

        # Options
        opt_frame = ttk.LabelFrame(root, text="Styling Options", padding=8)
        opt_frame.pack(fill="x", pady=(0, 10))

        # Row 1: Basic options
        opts_row1 = ttk.Frame(opt_frame)
        opts_row1.pack(fill="x", pady=(0, 8))
        
        self.translate_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(opts_row1, text="Translate to English", variable=self.translate_var).pack(side="left")

        ttk.Label(opts_row1, text="   Format:").pack(side="left")
        self.sub_format_var = tk.StringVar(value="ass")
        format_combo = ttk.Combobox(opts_row1, textvariable=self.sub_format_var, width=8, state="readonly",
                                   values=["ass", "srt"])
        format_combo.pack(side="left", padx=6)
        
        ttk.Label(opts_row1, text="   Angle (°):").pack(side="left")
        self.angle_var = tk.StringVar(value="135")
        angle_entry = ttk.Entry(opts_row1, textvariable=self.angle_var, width=6)
        angle_entry.pack(side="left", padx=6)
        
        ttk.Label(opts_row1, text="   Font adjust:").pack(side="left")
        self.font_adjust_var = tk.StringVar(value="0")
        font_adjust = ttk.Combobox(opts_row1, textvariable=self.font_adjust_var, width=6, state="readonly",
                                   values=["-8", "-4", "0", "+4", "+8"])
        font_adjust.pack(side="left", padx=6)
        
        ttk.Label(opts_row1, text="   Model:").pack(side="left")
        self.model_var = tk.StringVar(value="auto")
        model_combo = ttk.Combobox(opts_row1, textvariable=self.model_var, width=10, state="readonly",
                                   values=["auto", "tiny", "base", "small", "medium", "large-v3"])
        model_combo.pack(side="left", padx=6)

        # Row 2: Line control options
        opts_row2 = ttk.Frame(opt_frame)
        opts_row2.pack(fill="x", pady=(0, 8))
        
        ttk.Label(opts_row2, text="Line control:", font=("Segoe UI", 10, "bold")).pack(side="left")
        
        ttk.Label(opts_row2, text="   Max chars/line:").pack(side="left", padx=(10, 0))
        self.chars_per_line_var = tk.IntVar(value=35)
        chars_spin = ttk.Spinbox(opts_row2, from_=15, to=60, width=5, textvariable=self.chars_per_line_var)
        chars_spin.pack(side="left", padx=6)
        
        ttk.Label(opts_row2, text="   Max lines:").pack(side="left", padx=(10, 0))
        self.max_lines_var = tk.IntVar(value=2)
        lines_combo = ttk.Combobox(opts_row2, textvariable=self.max_lines_var, width=5, state="readonly",
                                   values=[1, 2, 3])
        lines_combo.pack(side="left", padx=6)
        
        ttk.Label(opts_row2, text="   Preview:").pack(side="left", padx=(10, 0))
        self.preview_btn = ttk.Button(opts_row2, text="Test wrapping", command=self._test_wrapping, width=12)
        self.preview_btn.pack(side="left", padx=6)
        
        ttk.Label(opts_row2, text="   Save .ass/.srt:").pack(side="left", padx=(10, 0))
        self.save_sub_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(opts_row2, variable=self.save_sub_var).pack(side="left")

        # Row 3: Vertical position control
        opts_row3 = ttk.Frame(opt_frame)
        opts_row3.pack(fill="x")
        
        ttk.Label(opts_row3, text="Vertical position:", font=("Segoe UI", 10, "bold")).pack(side="left")
        
        self.y_offset_var = tk.IntVar(value=0)
        y_offset_slider = ttk.Scale(opts_row3, from_=-50, to=100, orient="horizontal", 
                                    variable=self.y_offset_var, length=200)
        y_offset_slider.pack(side="left", padx=(10, 6))
        
        self.y_offset_label = ttk.Label(opts_row3, text="0 px", width=8)
        self.y_offset_label.pack(side="left")
        
        def update_offset_label(*args):
            self.y_offset_label.config(text=f"{self.y_offset_var.get()} px")
        self.y_offset_var.trace_add("write", update_offset_label)
        
        ttk.Label(opts_row3, text="   (Negative = up, Positive = down)", foreground="#666").pack(side="left", padx=(10, 0))

        # Progress
        prog_frame = ttk.LabelFrame(root, text="Progress", padding=8)
        prog_frame.pack(fill="x", pady=(0, 10))

        self.progress_var = tk.IntVar(value=0)
        self.progress_bar = ttk.Progressbar(prog_frame, variable=self.progress_var, maximum=100, length=400)
        self.progress_bar.pack(fill="x")
        self.status_label = ttk.Label(prog_frame, text="Ready.", foreground="#555")
        self.status_label.pack(anchor="w", pady=(4, 0))

        # Log
        log_frame = ttk.LabelFrame(root, text="Log", padding=8)
        log_frame.pack(fill="both", expand=True, pady=(0, 10))
        self.log_box = scrolledtext.ScrolledText(log_frame, height=8, font=("Courier New", 9),
                                                 bg="#F4F3EF", relief="flat", wrap="word")
        self.log_box.pack(fill="both", expand=True)

        # Buttons
        btn_row = ttk.Frame(root)
        btn_row.pack(fill="x")
        self.run_btn = ttk.Button(btn_row, text="▶  Start Processing", command=self._start,
                                  style="Accent.TButton")
        self.run_btn.pack(side="left")
        ttk.Button(btn_row, text="Clear log", command=self._clear_log).pack(side="left", padx=8)
        ttk.Button(btn_row, text="Check video info", command=self._check_video_info).pack(side="left", padx=8)

        if not check_ffmpeg():
            self._log("⚠  ffmpeg not found in PATH — please install it.")
        if not check_ffprobe():
            self._log("⚠  ffprobe not found — auto-scaling may use defaults.")

    def _test_wrapping(self):
        """Test the text wrapping with current settings."""
        sample_text = "This is a sample subtitle text to test how the character-based wrapping works with your current settings"
        chars_per_line = self.chars_per_line_var.get()
        max_lines = self.max_lines_var.get()
        wrapped = wrap_text_by_chars(sample_text, chars_per_line, max_lines)
        
        preview_msg = f"""Current settings:
• Max characters per line: {chars_per_line}
• Max lines: {max_lines}

Original text:
{sample_text}

Wrapped text ({len(wrapped.split(chr(10)))} lines):
{wrapped}"""
        
        messagebox.showinfo("Text Wrapping Preview", preview_msg)
        self._log(f"Preview: {chars_per_line} chars/line, {max_lines} lines max → {wrapped.replace(chr(10), ' | ')}")
    
    def _check_video_info(self):
        """Get and display video dimensions."""
        video_in = self.input_var.get().strip()
        if not video_in or not Path(video_in).exists():
            messagebox.showwarning("Warning", "Please select a valid video file first.")
            return
        
        try:
            width, height = get_video_dimensions(video_in)
            base_font = calculate_scaled_font_size(height)
            font_size = base_font + int(self.font_adjust_var.get() or 0)
            recommended_chars = max(20, int(40 * (width / 1920)))
            
            info_msg = f"""Video Dimensions: {width} x {height}
Scale Factor: {height/1080:.2f}

Calculated Styling:
• Font Size: {font_size}px (base: {base_font}px)
• Color: Gold (#FEBD00)

Recommended Line Settings:
• Max chars/line: {recommended_chars} (current: {self.chars_per_line_var.get()})

Current Settings:
• Max chars/line: {self.chars_per_line_var.get()}
• Max lines: {self.max_lines_var.get()}
• Vertical offset: {self.y_offset_var.get()}px
• Angle: {self.angle_var.get()}°"""
            
            messagebox.showinfo("Video Information", info_msg)
            self._log(f"Video info: {width}x{height} → Font: {font_size}, Rec chars/line: {recommended_chars}")
        except Exception as e:
            messagebox.showerror("Error", f"Could not read video info: {e}")
    
    def _detect_resources_async(self):
        def _run():
            try:
                r = detect_resources()
                self.resources = r
                parts = [f"CPU: {r['cpu_cores']} cores"]
                if r.get("ram_gb"):
                    parts.append(f"RAM: {r['ram_gb']} GB")
                if r.get("gpu"):
                    parts.append(f"GPU: {r['gpu']}")
                    if r.get("vram_gb"):
                        parts.append(f"VRAM: {r['vram_gb']} GB")
                parts.append(f"→ Will use: {r['device'].upper()} / {r['compute_type']} / model={r['model_size']}")
                self.after(0, lambda: self.res_label.config(text="   ".join(parts), foreground="#333"))
                self.after(0, lambda: self._log("Resource detection complete: " + " | ".join(parts)))
            except Exception as e:
                self.after(0, lambda: self.res_label.config(text=f"Detection error: {e}", foreground="red"))
        threading.Thread(target=_run, daemon=True).start()

    def _browse_input(self):
        path = filedialog.askopenfilename(
            title="Select video file",
            filetypes=[("Video files", "*.mp4 *.mkv *.mov *.avi *.webm *.m4v *.flv"), ("All files", "*.*")]
        )
        if path:
            self.input_var.set(path)
            if not self.output_var.get():
                p = Path(path)
                self.output_var.set(str(p.parent / (p.stem + "_subtitled" + p.suffix)))
            try:
                width, height = get_video_dimensions(path)
                self.video_dimensions = (width, height)
                self._log(f"Detected video: {width}x{height}")
                suggested = max(20, int(40 * (width / 1920)))
                self._log(f"Suggested max characters per line: {suggested}")
            except Exception:
                pass

    def _browse_output(self):
        path = filedialog.asksaveasfilename(
            title="Save output video",
            defaultextension=".mp4",
            filetypes=[("MP4 video", "*.mp4"), ("MKV video", "*.mkv"), ("All files", "*.*")]
        )
        if path:
            self.output_var.set(path)

    def _log(self, msg):
        def _do():
            self.log_box.insert("end", msg + "\n")
            self.log_box.see("end")
        self.after(0, _do)

    def _clear_log(self):
        self.log_box.delete("1.0", "end")

    def _set_status(self, msg):
        self.after(0, lambda: self.status_label.config(text=msg))

    def _set_progress(self, pct):
        self.after(0, lambda: self.progress_var.set(pct))

    def _start(self):
        if self.processing:
            return

        video_in = self.input_var.get().strip()
        video_out = self.output_var.get().strip()

        if not video_in:
            messagebox.showerror("Error", "Please select an input video.")
            return
        if not Path(video_in).exists():
            messagebox.showerror("Error", f"Input file not found:\n{video_in}")
            return
        if not video_out:
            messagebox.showerror("Error", "Please specify an output path.")
            return
        if not check_ffmpeg():
            messagebox.showerror("Error", "ffmpeg not found in PATH.\nPlease install ffmpeg and ensure it is on your PATH.")
            return

        self.processing = True
        self.run_btn.config(state="disabled", text="Processing…")
        self._set_progress(0)
        threading.Thread(target=self._process_thread, args=(video_in, video_out), daemon=True).start()

    def _process_thread(self, video_in, video_out):
        try:
            r = self.resources or detect_resources()
            model_size = self.model_var.get()
            if model_size == "auto":
                model_size = r["model_size"]
            device = r["device"]
            compute_type = r["compute_type"]
            translate = self.translate_var.get()
            subtitle_format = self.sub_format_var.get()
            font_adjust = int(self.font_adjust_var.get() or 0)
            chars_per_line = self.chars_per_line_var.get()
            max_lines = self.max_lines_var.get()
            y_offset = self.y_offset_var.get()
            
            try:
                angle = int(self.angle_var.get())
            except ValueError:
                angle = 135
                self._log(f"⚠ Invalid angle, using default 135°")
            
            try:
                video_width, video_height = get_video_dimensions(video_in)
                self._log(f"Video dimensions: {video_width}x{video_height}")
                base_font_size = calculate_scaled_font_size(video_height)
                final_font_size = base_font_size + font_adjust
                shadow = calculate_scaled_shadow(video_height)
                self._log(f"Scaling: Base font {base_font_size}px + adjust {font_adjust} = {final_font_size}px")
                self._log(f"Line settings: {chars_per_line} chars/line, max {max_lines} lines")
                self._log(f"Vertical offset: {y_offset}px")
            except Exception as e:
                video_width, video_height = 1920, 1080
                final_font_size = 36
                self._log(f"⚠ Could not detect video dimensions, using defaults")

            self._log(f"\n{'='*60}")
            self._log(f"Input:  {video_in}")
            self._log(f"Output: {video_out}")
            self._log(f"Model: {model_size}  Device: {device}  Translate: {translate}")
            self._log(f"Format: {subtitle_format.upper()}  Angle: {angle}°  Font: {final_font_size}px")
            self._log(f"Line wrapping: {chars_per_line} chars/line, max {max_lines} lines")
            self._log(f"{'='*60}")
            self._set_status("Transcribing audio…")
            self._set_progress(2)

            try:
                import faster_whisper
            except ImportError:
                self._log("❌ faster-whisper not installed. Run: pip install faster-whisper")
                raise RuntimeError("faster-whisper missing — see log.")

            segments, lang = transcribe_video(
                video_in, device, compute_type, model_size, translate,
                progress_cb=self._set_progress,
                log_cb=self._log,
            )

            if not segments:
                raise RuntimeError("No speech detected in the video.")

            self._log(f"\nDetected {len(segments)} subtitle segments.")
            self._set_status("Generating subtitles…")
            self._set_progress(85)

            if subtitle_format == "ass":
                subtitle_content = segments_to_ass(segments, video_width, video_height, angle, chars_per_line, max_lines, y_offset)
                subtitle_ext = ".ass"
            else:
                def fmt_time(t):
                    h = int(t // 3600)
                    m = int((t % 3600) // 60)
                    s = int(t % 60)
                    ms = int((t - int(t)) * 1000)
                    return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
                
                srt_lines = []
                for i, (start, end, text) in enumerate(segments, 1):
                    wrapped_text = wrap_text_by_chars(text, chars_per_line, max_lines)
                    srt_lines.append(str(i))
                    srt_lines.append(f"{fmt_time(start)} --> {fmt_time(end)}")
                    srt_lines.append(wrapped_text)
                    srt_lines.append("")
                subtitle_content = "\n".join(srt_lines)
                subtitle_ext = ".srt"

            subtitle_path = Path(video_out).with_suffix(subtitle_ext)
            subtitle_path.write_text(subtitle_content, encoding="utf-8")
            self._log(f"Subtitle file saved: {subtitle_path}")

            self._set_status(f"Burning subtitles ({subtitle_format.upper()})…")
            self._set_progress(88)

            if subtitle_format == "ass":
                burn_subtitles_ass(video_in, subtitle_path, video_out,
                                   progress_cb=self._set_progress,
                                   log_cb=self._log)
            else:
                escaped_path = str(subtitle_path).replace('\\', '/')
                if platform.system() == "Windows":
                    escaped_path = escaped_path.replace(':', '\\:')
                style = f"FontName=Arial,FontSize={final_font_size},PrimaryColour=&H0000BDFE,OutlineColour=&H00000000,Bold=-1,Shadow=1,Alignment=2"
                cmd = [
                    "ffmpeg", "-y",
                    "-i", str(video_in),
                    "-vf", f"subtitles='{escaped_path}':force_style='{style}'",
                    "-c:a", "copy",
                    "-preset", "fast",
                    str(video_out),
                ]
                subprocess.run(cmd, capture_output=True, check=True)

            if not self.save_sub_var.get():
                subtitle_path.unlink(missing_ok=True)
                self._log(f"Subtitle file deleted")

            self._set_progress(100)
            self._set_status(f"✅ Done! Saved to {video_out}")
            self._log(f"\n✅ All done → {video_out}")
            self.after(0, lambda: messagebox.showinfo("Done", f"Video saved:\n{video_out}"))

        except Exception as e:
            self._log(f"\n❌ Error: {e}")
            self._set_status(f"Error: {e}")
            import traceback
            self._log(traceback.format_exc())
            self.after(0, lambda: messagebox.showerror("Processing failed", str(e)))

        finally:
            self.processing = False
            self.after(0, lambda: self.run_btn.config(state="normal", text="▶  Start Processing"))


# ──────────────────────────────────────────────
# Entry point
# ──────────────────────────────────────────────

if __name__ == "__main__":
    app = App()
    app.mainloop()