"""
ClutchCam streamer tool.

Every couple of seconds it takes a picture of one small corner of the screen,
reads the "players left" number, and sends it to the ClutchCam server.
It only looks at the screen (like OBS does). It never touches the game.
"""
import base64
import io
import json
import os
import re
import sys
import threading
import time
import tkinter as tk
import urllib.request
from tkinter import ttk

import mss
from PIL import Image

SERVER = "https://clutchcam.live"
CHECK_EVERY = 2          # seconds between screen checks
LIVE_CHECK_EVERY = 5     # seconds between "has OBS gone live?" checks (auto start)
# Auto start (start with Windows, start when OBS goes live) is built but switched
# off for now: we want streamers to open ClutchCam and press Start themselves.
# Change to True to bring the two tick boxes back.
AUTO_START_FEATURE = False
MISSES_BEFORE_OUT = 3    # this many unreadable checks in a row = not in a match

# Where each game shows its counter, as fractions of the screen
# (left, top, right, bottom). Generous so different HUD layouts still fit:
# Fortnite players can put the counter beside or below the minimap.
GAMES = {
    "fortnite": {"name": "Fortnite", "area": (0.80, 0.00, 1.00, 0.32)},
    "pubg":     {"name": "PUBG",     "area": (0.75, 0.00, 1.00, 0.12)},
}

# Keep settings next to the program so they survive restarts.
HERE = os.path.dirname(sys.executable if getattr(sys, "frozen", False) else os.path.abspath(__file__))
SETTINGS_FILE = os.path.join(HERE, "finalcircle_settings.json")
TEST_PICTURE = os.path.join(HERE, "what_the_tool_sees.png")
# Bump this date when the Terms or Privacy Policy change, so streamers agree again.
TERMS_VERSION = "2026-10-30.2"

_ocr = None


def ocr_engine():
    global _ocr
    if _ocr is None:
        from rapidocr import RapidOCR
        _ocr = RapidOCR()
    return _ocr


# ---------- Reading the screen ----------

def list_screens():
    """Friendly names for each screen, in the tool's screen-number order.
    Screens are described by where they sit (left, right, middle), and the
    Windows main screen is marked, because numbers mean nothing to people."""
    with mss.mss() as screen:
        mons = screen.monitors[1:]
    by_position = sorted(range(len(mons)), key=lambda i: mons[i]["left"])
    names = [""] * len(mons)
    for rank, i in enumerate(by_position):
        if len(mons) == 1:
            name = "Your screen"
        elif len(mons) == 2:
            name = "Left screen" if rank == 0 else "Right screen"
        else:
            name = f"Screen {rank + 1} from the left"
        if mons[i]["left"] == 0 and mons[i]["top"] == 0 and len(mons) > 1:
            name += " (main)"
        names[i] = name
    return names


def crop_to_counter(game, img):
    """Cut out the corner where the game shows its counter, and enlarge it."""
    left, top, right, bottom = GAMES[game]["area"]
    w, h = img.size
    img = img.crop((int(w * left), int(h * top), int(w * right), int(h * bottom)))
    # Bigger text reads more reliably.
    return img.resize((img.width * 2, img.height * 2), Image.LANCZOS)


def grab_screen(screen_number=1):
    """A picture of one whole screen."""
    with mss.mss() as screen:
        if screen_number >= len(screen.monitors):
            screen_number = 1   # that screen has been unplugged; use the main one
        shot = screen.grab(screen.monitors[screen_number])
    return Image.frombytes("RGB", shot.size, shot.rgb)


# Kinds of OBS source that carry a game picture, shown first in the list.
VIDEO_KINDS = ("dshow_input", "game_capture", "window_capture", "monitor_capture",
               "av_capture_input", "ffmpeg_source", "v4l2_input", "xshm_input")


class ObsReader:
    """Gets pictures of the game straight from OBS, through the WebSocket
    server built into OBS (Tools > WebSocket Server Settings)."""

    def __init__(self, password, port=4455):
        import obsws_python as obs
        self.client = obs.ReqClient(host="localhost", port=port, password=password, timeout=3)

    def sources(self):
        inputs = self.client.get_input_list().inputs
        def is_video(i):
            return any(i.get("inputKind", "").startswith(k) for k in VIDEO_KINDS)
        video = [i["inputName"] for i in inputs if is_video(i)]
        other = [i["inputName"] for i in inputs if not is_video(i)]
        return video + other

    def streaming(self):
        """True while OBS is live on Twitch."""
        return bool(self.client.get_stream_status().output_active)

    def grab(self, source):
        reply = self.client.get_source_screenshot(source, "png", 1920, 1080, -1)
        data = reply.image_data.split(",", 1)[1]   # drop the "data:image/png;base64," start
        return Image.open(io.BytesIO(base64.b64decode(data))).convert("RGB")


def explain_obs_problem(error):
    """Turn OBS connection errors into something a streamer can act on."""
    text = str(error).lower()
    # We reached OBS but it turned us away: that's the password.
    if "identify" in text or "auth" in text or "password" in text or "connection to remote host was lost" in text:
        return "OBS didn't accept that password. Copy it again from OBS's WebSocket Server Settings."
    if isinstance(error, ConnectionRefusedError) or "refused" in text or "connect" in text:
        return ("Can't reach OBS. Make sure OBS is open, then in OBS go to Tools, "
                "WebSocket Server Settings and tick Enable WebSocket server.")
    if "timed out" in text or "timeout" in text:
        return "OBS didn't answer in time. Is it busy or frozen?"
    return f"OBS problem: {error}"


def read_text(img):
    """Returns the pieces of text found, each as (centre_x, centre_y, text)."""
    import numpy as np
    result = ocr_engine()(np.array(img))
    if not result.txts:
        return []
    pieces = []
    for box, text in zip(result.boxes, result.txts):
        xs = [float(p[0]) for p in box]
        ys = [float(p[1]) for p in box]
        pieces.append((sum(xs) / 4, sum(ys) / 4, text.strip()))
    return pieces


def find_players_left(game, pieces):
    """Pull the players-left number out of what we read, or None."""
    if game == "fortnite":
        # Fortnite always puts players-left right next to the storm timer
        # (beside it or just above it, depending on the player's HUD layout),
        # so we find the timer and take the nearest number to it.
        timers = [p for p in pieces if re.fullmatch(r"\d{1,2}[:.]\d{2}", p[2])]
        numbers = [p for p in pieces if re.fullmatch(r"\d{1,3}", p[2]) and 1 <= int(p[2]) <= 100]
        if not timers or not numbers:
            return None
        tx, ty, _ = timers[0]
        nearest = min(numbers, key=lambda p: (p[0] - tx) ** 2 + (p[1] - ty) ** 2)
        return int(nearest[2])
    if game == "pubg":
        # PUBG shows e.g. "45 ALIVE". The reader sometimes returns the number
        # and the word as one piece ("45ALIVE"), sometimes as two pieces, and
        # not always in reading order, so we find the word ALIVE and take the
        # number joined to it, or else the nearest separate number.
        alive = [p for p in pieces if re.search(r"AL[I1l|!]VE", p[2], re.IGNORECASE)]
        for p in alive:
            m = re.search(r"(\d{1,3})\s*AL[I1l|!]VE", p[2], re.IGNORECASE)
            if m and 1 <= int(m.group(1)) <= 100:
                return int(m.group(1))
        numbers = [p for p in pieces if re.fullmatch(r"\d{1,3}", p[2]) and 1 <= int(p[2]) <= 100]
        if alive and numbers:
            ax, ay, _ = alive[0]
            nearest = min(numbers, key=lambda p: (p[0] - ax) ** 2 + (p[1] - ay) ** 2)
            # Only trust it if it's close by, on roughly the same line.
            if abs(nearest[1] - ay) < 40 and abs(nearest[0] - ax) < 250:
                return int(nearest[2])
    return None


class Smoother:
    """Ignores one-off misreads. In a match the count only goes down, so a
    number that jumps up only counts once we've seen it twice in a row
    (that means a new match has really started)."""

    def __init__(self):
        self.last = None
        self.misses = 0
        self.pending = None

    def update(self, n):
        if n is None:
            self.misses += 1
            if self.misses >= MISSES_BEFORE_OUT:
                self.last = None
            return self.last
        self.misses = 0
        if self.last is None or n <= self.last:
            self.last, self.pending = n, None
        elif self.pending is not None and abs(n - self.pending) <= 3:
            self.last, self.pending = n, None
        else:
            self.pending = n
        return self.last


# ---------- Starting with Windows ----------

RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"


def starts_with_windows():
    try:
        import winreg
        with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as k:
            winreg.QueryValueEx(k, "ClutchCam")
        return True
    except (ImportError, OSError):
        return False


def set_start_with_windows(on):
    """Adds or removes ClutchCam from the programs Windows opens at sign-in.
    Returns an error message, or None if it worked."""
    try:
        import winreg
    except ImportError:
        return "Starting with Windows only works on Windows."
    try:
        with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, 0, winreg.KEY_SET_VALUE) as k:
            if on:
                # pythonw runs ClutchCam without a Command Prompt window.
                pythonw = os.path.join(os.path.dirname(sys.executable), "pythonw.exe")
                if not os.path.exists(pythonw):
                    pythonw = sys.executable
                script = os.path.abspath(__file__)
                winreg.SetValueEx(k, "ClutchCam", 0, winreg.REG_SZ,
                                  f'"{pythonw}" "{script}" --autostart')
            else:
                try:
                    winreg.DeleteValue(k, "ClutchCam")
                except FileNotFoundError:
                    pass
        return None
    except OSError as e:
        return f"Couldn't change the Windows start-up setting: {e}"


# ---------- Talking to the server ----------

def send(channel, key, game, players_left):
    body = {
        "channel": channel,
        "game": game,
        "players_left": players_left or 0,
        "in_match": players_left is not None,
    }
    req = urllib.request.Request(
        SERVER + "/api/report",
        data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json", "X-Streamer-Key": key},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=10) as resp:
        return resp.status


# ---------- The window ----------

class App:
    def __init__(self, root, autostarted=False):
        self.root = root
        self.running = False
        self.started_by_auto = False   # True if "go live" started it, so "stream ended" stops it
        self.held_off = False          # streamer pressed Stop while live: don't restart until next stream
        self.obs = None                # live connection to OBS
        self.obs_lock = threading.Lock()   # OBS only handles one request at a time
        root.title("ClutchCam")
        root.resizable(False, False)
        settings = self.load()

        frame = ttk.Frame(root, padding=16)
        frame.grid()

        ttk.Label(frame, text="Your Twitch name").grid(row=0, column=0, sticky="w")
        self.channel = tk.StringVar(value=settings.get("channel", ""))
        ttk.Entry(frame, textvariable=self.channel, width=32).grid(row=1, column=0, pady=(0, 8))

        ttk.Label(frame, text="Your ClutchCam key").grid(row=2, column=0, sticky="w")
        self.key = tk.StringVar(value=settings.get("key", ""))
        ttk.Entry(frame, textvariable=self.key, width=32, show="*").grid(row=3, column=0, pady=(0, 8))

        ttk.Label(frame, text="Game").grid(row=4, column=0, sticky="w")
        names = [g["name"] for g in GAMES.values()]
        self.game_box = ttk.Combobox(frame, values=names, state="readonly", width=29)
        self.game_box.set(GAMES[settings.get("game", "fortnite")]["name"])
        self.game_box.grid(row=5, column=0, pady=(0, 8))

        ttk.Label(frame, text="Read the game from").grid(row=6, column=0, sticky="w")
        self.mode_box = ttk.Combobox(frame, values=["My screen", "OBS"], state="readonly", width=29)
        self.mode_box.set("OBS" if settings.get("mode") == "obs" else "My screen")
        self.mode_box.grid(row=7, column=0, pady=(0, 8))
        self.mode_box.bind("<<ComboboxSelected>>", lambda e: self.show_mode())

        # Screen mode: which screen to watch.
        self.screen_frame = ttk.Frame(frame)
        ttk.Label(self.screen_frame, text="Screen the game is on").grid(row=0, column=0, sticky="w")
        screens = list_screens()
        self.screen_box = ttk.Combobox(self.screen_frame, values=screens, state="readonly", width=29)
        saved = settings.get("screen", 1)
        self.screen_box.current(saved - 1 if saved <= len(screens) else 0)
        self.screen_box.grid(row=1, column=0)

        # OBS mode: which OBS source has the game in it.
        self.source_frame = ttk.Frame(frame)
        ttk.Label(self.source_frame, text="OBS source with your game").grid(row=0, column=0, sticky="w")
        self.source_box = ttk.Combobox(self.source_frame, state="readonly", width=29)
        if settings.get("obs_source"):
            self.source_box["values"] = [settings["obs_source"]]
            self.source_box.set(settings["obs_source"])
        self.source_box.grid(row=1, column=0)

        self.screen_frame.grid(row=8, column=0, pady=(0, 8), sticky="w")
        self.source_frame.grid(row=8, column=0, pady=(0, 8), sticky="w")

        # OBS connection: needed for OBS mode, and for auto start.
        self.conn_frame = ttk.Frame(frame)
        ttk.Label(self.conn_frame, text="OBS WebSocket password").grid(row=0, column=0, sticky="w")
        self.obs_password = tk.StringVar(value=settings.get("obs_password", ""))
        ttk.Entry(self.conn_frame, textvariable=self.obs_password, width=32, show="*").grid(row=1, column=0)
        ttk.Button(self.conn_frame, text="Connect to OBS", command=self.connect_obs).grid(
            row=2, column=0, sticky="ew", pady=(6, 0))
        self.conn_frame.grid(row=9, column=0, pady=(0, 10), sticky="w")
        # The background thread reads this copy, never the box itself.
        self.obs_pw = self.obs_password.get()
        self.obs_password.trace_add("write", lambda *a: self._password_changed())

        self.auto_var = tk.BooleanVar(value=settings.get("auto", False) and AUTO_START_FEATURE)
        self.auto_on = self.auto_var.get()
        self.boot_var = tk.BooleanVar(value=starts_with_windows())
        if AUTO_START_FEATURE:
            ttk.Checkbutton(frame, text="Start automatically when I go live in OBS",
                            variable=self.auto_var, command=self.auto_changed).grid(row=10, column=0, sticky="w")
            ttk.Checkbutton(frame, text="Open ClutchCam when Windows starts",
                            variable=self.boot_var, command=self.boot_changed).grid(
                row=11, column=0, sticky="w", pady=(2, 12))
        elif self.boot_var.get():
            # The feature is switched off: undo it for anyone who turned it on earlier.
            set_start_with_windows(False)
            self.boot_var.set(False)

        self.button = ttk.Button(frame, text="Start", command=self.toggle)
        self.button.grid(row=12, column=0, sticky="ew")
        ttk.Button(frame, text="Save a test picture", command=self.test_picture).grid(
            row=13, column=0, sticky="ew", pady=(6, 0))

        self.status = tk.StringVar(value="Not running")
        ttk.Label(frame, textvariable=self.status, wraplength=260).grid(row=14, column=0, pady=(12, 0))

        self.show_mode()
        if self.auto_on:
            self.say("Waiting for you to go live in OBS...")
        threading.Thread(target=self.watch_for_live, daemon=True).start()
        if autostarted:
            root.iconify()   # opened by Windows at start-up: stay out of the way

    # ---------- reading the settings on screen ----------

    def using_obs(self):
        return self.mode_box.get() == "OBS"

    def show_mode(self):
        if self.using_obs():
            self.screen_frame.grid_remove()
            self.source_frame.grid()
        else:
            self.source_frame.grid_remove()
            self.screen_frame.grid()
        # The OBS password is only needed for OBS mode or auto start.
        if self.using_obs() or self.auto_var.get():
            self.conn_frame.grid()
        else:
            self.conn_frame.grid_remove()

    def chosen_screen(self):
        return self.screen_box.current() + 1

    def chosen_game(self):
        name = self.game_box.get()
        return next(k for k, g in GAMES.items() if g["name"] == name)

    def load(self):
        try:
            with open(SETTINGS_FILE) as f:
                return json.load(f)
        except (OSError, ValueError):
            return {}

    def save(self):
        with open(SETTINGS_FILE, "w") as f:
            json.dump({"channel": self.channel.get().strip(),
                       "key": self.key.get().strip(),
                       "game": self.chosen_game(),
                       "mode": "obs" if self.using_obs() else "screen",
                       "screen": self.chosen_screen(),
                       "obs_password": self.obs_password.get(),
                       "obs_source": self.source_box.get(),
                       "auto": self.auto_var.get(),
                       "terms_agreed": TERMS_VERSION}, f)

    def say(self, text):
        self.root.after(0, self.status.set, text)

    def _password_changed(self):
        self.obs_pw = self.obs_password.get()
        with self.obs_lock:
            self.obs = None   # reconnect with the new password

    def missing_settings(self):
        """What's still needed before ClutchCam can run, or None."""
        if not self.channel.get().strip() or not self.key.get().strip():
            return "Fill in your Twitch name and key first."
        if self.using_obs() and not self.source_box.get():
            return "Connect to OBS and pick your game source first."
        return None

    # ---------- tick boxes ----------

    def auto_changed(self):
        self.auto_on = self.auto_var.get()
        self.show_mode()
        try:
            self.save()
        except OSError:
            pass
        if self.auto_on:
            self.say("Auto start is on. Fill in your OBS password if it's empty, "
                     "and ClutchCam will start when you go live.")
        else:
            self.say("Auto start is off. Press Start yourself when you stream.")

    def boot_changed(self):
        problem = set_start_with_windows(self.boot_var.get())
        if problem:
            self.boot_var.set(starts_with_windows())
            self.say(problem)
        elif self.boot_var.get():
            self.say("ClutchCam will open when Windows starts.")
        else:
            self.say("ClutchCam won't open when Windows starts.")

    # ---------- OBS ----------

    def obs_client(self):
        """The OBS connection, made if needed. Call while holding obs_lock."""
        if self.obs is None:
            self.obs = ObsReader(self.obs_pw)
        return self.obs

    def connect_obs(self):
        self.say("Connecting to OBS...")
        threading.Thread(target=self._connect_obs, daemon=True).start()

    def _connect_obs(self):
        try:
            with self.obs_lock:
                self.obs = None
                sources = self.obs_client().sources()
        except Exception as e:
            with self.obs_lock:
                self.obs = None
            self.say(explain_obs_problem(e))
            return

        def fill():
            self.source_box["values"] = sources
            if self.source_box.get() not in sources and sources:
                self.source_box.set(sources[0])
            if self.using_obs():
                self.say("Connected to OBS. Pick the source that shows your game, "
                         "usually your capture card.")
            else:
                self.say("Connected to OBS.")
        self.root.after(0, fill)

    def obs_is_live(self):
        """True or False, or None if OBS can't be reached right now."""
        try:
            with self.obs_lock:
                return self.obs_client().streaming()
        except Exception:
            with self.obs_lock:
                self.obs = None
            return None

    def watch_for_live(self):
        """Runs in the background the whole time ClutchCam is open. With auto
        start on, it starts ClutchCam when OBS goes live and stops it after."""
        while True:
            time.sleep(LIVE_CHECK_EVERY)
            if not self.auto_on:
                continue
            live = self.obs_is_live()
            if live is None:
                if not self.running:
                    self.say("Auto start: waiting for OBS. Is it open, with the "
                             "WebSocket server on and the right password here?")
                continue
            if live and not self.running and not self.held_off:
                self.root.after(0, self.start, True)
            elif not live:
                self.held_off = False
                if self.running and self.started_by_auto:
                    self.root.after(0, self.stop, "Your stream ended, so ClutchCam stopped. "
                                                  "It'll start again when you go live.")
                elif not self.running:
                    self.say("Waiting for you to go live in OBS...")

    # ---------- getting a picture of the game ----------

    def game_picture(self, use_obs, screen_number, source):
        if not use_obs:
            return grab_screen(screen_number)
        try:
            with self.obs_lock:
                return self.obs_client().grab(source)
        except Exception as e:
            with self.obs_lock:
                self.obs = None   # connect again next time
            raise RuntimeError(explain_obs_problem(e)) from e

    # ---------- starting and stopping ----------

    def start(self, automatic=False):
        if self.running:
            return
        problem = self.missing_settings()
        if problem:
            self.say(("You're live, but ClutchCam can't start yet. " if automatic else "") + problem)
            return
        self.save()
        self.running = True
        self.run_id = getattr(self, "run_id", 0) + 1   # so an older loop knows to bow out
        self.started_by_auto = automatic
        self.button.config(text="Stop")
        config = (self.run_id, self.channel.get().strip(), self.key.get().strip(), self.chosen_game(),
                  self.using_obs(), self.chosen_screen(), self.source_box.get())
        threading.Thread(target=self.loop, args=config, daemon=True).start()

    def stop(self, message="Stopped"):
        self.running = False
        self.started_by_auto = False
        self.button.config(text="Start")
        self.say(message)

    def toggle(self):
        if self.running:
            # If they stop it mid-stream, don't let auto start switch it straight back on.
            self.held_off = self.auto_on
            self.stop("Stopped" + (" until your next stream." if self.auto_on else ""))
        else:
            self.held_off = False
            self.start()

    def test_picture(self, seconds=5):
        if self.running:
            self.stop()
        # On screen mode, count down so there's time to bring the game to the front.
        # OBS doesn't care what's on screen, so it can go straight away.
        if seconds > 0 and not self.using_obs():
            self.say(f"Taking a test picture in {seconds}... bring your game or video to the front.")
            self.root.after(1000, self.test_picture, seconds - 1)
            return
        game = self.chosen_game()
        try:
            img = crop_to_counter(game, self.game_picture(
                self.using_obs(), self.chosen_screen(), self.source_box.get()))
        except Exception as e:
            self.say(str(e))
            return
        img.save(TEST_PICTURE)
        n = find_players_left(game, read_text(img))
        found = f"I can read {n} players left." if n else "I can't see a player count right now."
        self.say(f"{found} Saved what I can see to {os.path.basename(TEST_PICTURE)}")

    def loop(self, run_id, channel, key, game, use_obs, screen_number, source):
        smooth = Smoother()
        self.say("Starting up...")
        last_sent = 0
        mine = lambda: self.running and self.run_id == run_id
        while mine():
            try:
                img = crop_to_counter(game, self.game_picture(use_obs, screen_number, source))
                n = smooth.update(find_players_left(game, read_text(img)))
                now = time.time()
                # Send every check during a match; once every 10 seconds otherwise.
                if n is not None or now - last_sent >= 10:
                    send(channel, key, game, n)
                    last_sent = now
                if mine():
                    self.say(f"In a match: {n} players left" if n else "Waiting for a match...")
            except urllib.error.HTTPError as e:
                self.say("The server didn't accept your key. Check it and your Twitch name."
                         if e.code == 403 else f"Server problem ({e.code}). Retrying...")
            except RuntimeError as e:      # OBS problems, already explained
                self.say(f"{e} Retrying...")
            except Exception as e:
                self.say(f"Something went wrong: {e}. Retrying...")
            time.sleep(CHECK_EVERY)
        # Tell the site straight away that this stream has stopped
        # (unless a newer loop has taken over).
        if self.running:
            return
        try:
            send(channel, key, game, None)
        except Exception:
            pass


def ask_to_agree(root, then):
    """Shown once (and again if the terms change): the streamer must tick
    the box and press Continue before ClutchCam will run."""
    import webbrowser
    root.title("ClutchCam")
    root.resizable(False, False)
    box = ttk.Frame(root, padding=18)
    box.grid()
    ttk.Label(box, text="Welcome to ClutchCam", font=("Segoe UI", 13, "bold")).grid(row=0, column=0, sticky="w")
    ttk.Label(box, wraplength=300, justify="left", text=(
        "Before you start, please read the Terms of Use and Privacy Policy.\n\n"
        "In short: ClutchCam only reads the players-left number from your screen "
        "or OBS, and never touches your games. Pictures of your screen stay on "
        "your PC. You're responsible for your stream and for following Twitch's "
        "and each game's rules, and you use the app at your own risk."
    )).grid(row=1, column=0, sticky="w", pady=(8, 10))
    links = ttk.Frame(box)
    links.grid(row=2, column=0, sticky="w")
    ttk.Button(links, text="Read the Terms of Use",
               command=lambda: webbrowser.open(SERVER + "/terms")).grid(row=0, column=0, padx=(0, 6))
    ttk.Button(links, text="Read the Privacy Policy",
               command=lambda: webbrowser.open(SERVER + "/privacy")).grid(row=0, column=1)
    agreed = tk.BooleanVar(value=False)
    go = ttk.Button(box, text="Continue", state="disabled")
    ttk.Checkbutton(box, text="I agree to the Terms of Use and Privacy Policy", variable=agreed,
                    command=lambda: go.config(state="normal" if agreed.get() else "disabled")).grid(
        row=3, column=0, sticky="w", pady=(12, 8))

    def accept():
        try:
            with open(SETTINGS_FILE) as fh:
                saved = json.load(fh)
        except (OSError, ValueError):
            saved = {}
        saved["terms_agreed"] = TERMS_VERSION
        with open(SETTINGS_FILE, "w") as fh:
            json.dump(saved, fh)
        box.destroy()
        then()

    go.config(command=accept)
    go.grid(row=4, column=0, sticky="ew")
    ttk.Button(box, text="No thanks", command=root.destroy).grid(row=5, column=0, sticky="ew", pady=(6, 0))


def main():
    root = tk.Tk()
    try:
        with open(SETTINGS_FILE) as fh:
            already = json.load(fh).get("terms_agreed") == TERMS_VERSION
    except (OSError, ValueError):
        already = False
    if not already:
        ask_to_agree(root, lambda: start_app(root))
        root.mainloop()
        return
    start_app(root)
    root.mainloop()


def start_app(root):
    try:
        App(root, autostarted="--autostart" in sys.argv)
    except Exception as e:
        # Started from the Desktop icon there's no window to print errors in,
        # so show them in a box instead of failing silently.
        from tkinter import messagebox
        root.withdraw()
        messagebox.showerror("ClutchCam couldn't start",
                             f"{e}\n\nTry running the ClutchCam setup again from the setup page.")
        root.destroy()


if __name__ == "__main__":
    main()
