#!/usr/bin/env python3
"""
mailferry - IMAP account sync manager

Data flow
---------
Current  -- live working dict. Structure:
             user, password, host, oauth,
             user2, password2, host2, oauth2,
             + all advanced option keys
accounts -- keyed by user; each value is a frozen copy of Current.

Layout
------
  Row 1  -- Two credential panes side-by-side
  Row 2  -- Batch pane (Sync | Test Run | Advanced Options | Add to Batch)
           (dropdown | Sync All | Save List | Load List)
  Row 3  -- Output StaticBox (Cancel in header)

Dependencies:
    pip install wxpython
"""

import wx
import wx.adv
import wx.lib.scrolledpanel as scrolled
import sys
import json
import subprocess
import os
import re
import shlex
import signal
import platform
import tempfile
import threading
import time
import datetime
import queue as _queue_mod


# ---------------------------------------------------------------------------
# Application identity and data directory
# ---------------------------------------------------------------------------

APP_NAME         = 'mailferry'   # lowercase, no spaces -- used for dir name
APP_NAME_DISPLAY = 'MailFerry'   # used for macOS path


def _is_compiled() -> bool:
    # PyInstaller sets sys.frozen.
    if getattr(sys, 'frozen', False):
        return True
    # Nuitka sets __compiled__ on the __main__ module.
    main = sys.modules.get('__main__', None)
    if main and getattr(main, '__compiled__', False):
        return True
    return False


def _compiled_exe_dir() -> str:
    # For Nuitka --onefile on Windows, sys.executable points into the
    # temporary unpack directory (e.g. AppData\Local\Temp\...).
    # sys.argv[0] is always set to the path of the original .exe by the
    # Nuitka onefile bootstrap, so it gives us the real install directory.
    # We prefer sys.argv[0] when it looks like an absolute path to an .exe;
    # otherwise fall back to sys.executable (PyInstaller and non-onefile
    # Nuitka both set sys.executable correctly).
    candidate = os.path.abspath(sys.argv[0]) if sys.argv else ''
    if candidate and os.path.isfile(candidate):
        return os.path.dirname(candidate)
    return os.path.dirname(os.path.abspath(sys.executable))


def get_data_dir() -> str:
    # 1. Explicit environment variable override -- useful for testing
    env_override = os.environ.get('MAILFERRY_HOME')
    if env_override:
        data_dir = os.path.expanduser(env_override)

    # 2. Running as a compiled binary (PyInstaller or Nuitka).
    #    The data directory is always beside the binary so users can place
    #    imapsync next to the GUI executable and everything works.
    elif _is_compiled():
        data_dir = _compiled_exe_dir()

    # 3. Plain Python script.
    #    Determine whether this looks like a direct/local run or a system install:
    #
    #    a) script_dir == cwd  →  user ran ./mailferry.py or python mailferry.py
    #       from the script's own directory.  Use that directory regardless of
    #       whether tokens/ exists yet -- _ensure_data_dir will create it.
    #
    #    b) tokens/ already exists beside the script  →  previous run already
    #       created the data layout here (e.g. ran from a parent dir once before).
    #       Keep using script_dir.
    #
    #    c) script is in a system bin directory (/usr/bin, /usr/local/bin, etc.)
    #       →  use the XDG / platform user-data directory instead.
    else:
        script_dir = os.path.dirname(os.path.abspath(__file__))
        cwd        = os.path.abspath(os.getcwd())
        in_sys_bin = any(script_dir.startswith(p) for p in (
            '/usr/bin', '/usr/local/bin', '/opt/', '/snap/'))

        if not in_sys_bin and (
            script_dir == cwd or
            os.path.isdir(os.path.join(script_dir, 'tokens'))
        ):
            data_dir = script_dir
        else:
            data_dir = _xdg_or_home()

    _ensure_data_dir(data_dir)
    return data_dir


def _xdg_or_home() -> str:
    system = platform.system()
    if system == 'Darwin':
        base = os.path.expanduser(f'~/Library/Application Support/{APP_NAME_DISPLAY}')
    elif system == 'Linux':
        xdg = os.environ.get('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
        base = os.path.join(xdg, APP_NAME)
    else:
        base = os.path.expanduser(f'~/.{APP_NAME}')
    return base


def _ensure_data_dir(path: str):
    subdirs = ['tokens', 'LOG_imapsync']
    for subdir in subdirs:
        full_path = os.path.join(path, subdir)
        if not os.path.exists(full_path):
            os.makedirs(full_path, mode=0o700, exist_ok=True)
            os.chmod(full_path, 0o700)


DATA_DIR   = get_data_dir()
TOKENS_DIR = os.path.join(DATA_DIR, 'tokens')
LOG_DIR    = os.path.join(DATA_DIR, 'LOG_imapsync')

# Keys that live in an entry dict for internal use and must never be forwarded
# to imapsync as command-line flags.
_IMAPSYNC_EXCLUDED_KEYS = frozenset({'retries', 'extra', 'LastSync'})

# Matches imapsync ETA progress lines on both Linux and Windows.
# The timezone abbreviation varies by platform and may contain spaces and
# punctuation (e.g. "AWST" on Linux, "W. Australia Standard Time" on Windows),
# so we anchor on the known fixed tokens after the ETA timestamp and let the
# ETA string be everything between "ETA:" and the seconds field.
#
# Groups:
#   1 - full ETA string (everything between "ETA:" and the seconds value),
#       trimmed by the lazy .+? quantifier
#   2 - seconds field, e.g. "2 s" or "12.75 s" or "180.2 s"
#   3 - msg count, e.g. "5/12"
#
# Example lines:
#   ETA: Monday 06 April 2026-04-06 17:20:31 +0800 AWST  78 s  3/3 msgs left
#   ETA: Saturday 11 April 2026-04-11 13:00:36 +0800 W. Australia Standard Time  2 s  7/12 msgs left
#
# Compiled once at module load time.
_ETA_RE = re.compile(
    r'ETA:\s+'
    r'(.+?)'                    # group 1: ETA string (lazy, stops at seconds)
    r'\s+(\d+\.?\d*\s+s)'      # group 2: seconds, e.g. "2 s" or "12.75 s"
    r'\s+(\d+/\d+)'            # group 3: msg count, e.g. "5/12"
    r'\s+msgs\s+left'
)

# Sentinel string shown in the dropdown when the form is blank and ready for a
# new entry.  It is never stored in accounts and is stripped before any
# operation that iterates the real account keys.
_NEW_JOB_SENTINEL = '-- New Sync Job --'

# ---------------------------------------------------------------------------
# Global state
# ---------------------------------------------------------------------------

accounts = {}
Current  = {}

g_output      = None
g_timer       = None
g_process     = None
g_on_finish   = None
g_read_thread = None
g_queue       = None
g_pidfile     = None   # path to imapsync --pidfile temp file

g_dropdown   = None
g_btn_sync   = None
g_btn_cancel = None
g_progress   = None   # TextGauge for sync progress
g_sync_user  = None   # key of the account currently being synced

_cred1_fields = {}
_cred2_fields = {}

# ---------------------------------------------------------------------------
# Advanced options schema
# ---------------------------------------------------------------------------
# Each entry: (key, type, label, section)
# type: 'int' | 'str' | 'bool'
# section: 'src' | 'dst' | 'generic'

ADV_OPTS = [
    # Source Account
    ('port1',               'int',  'Custom IMAP Port',                                                                  'src'),
    ('exclude',             'str',  'Skip folders matching this regular expression (e.g. Drafts|Spam)',                  'src'),
    ('skipmess',            'str',  'Skip messages matching this regex',                                                 'src'),
    ('subfolder1',          'str',  'Sync only this folder to the root hierarchy of destination account',                'src'),
    ('maxsize',             'int',  'Skip messages larger than this many bytes',                                         'src'),
    ('minsize',             'int',  'Skip messages smaller than this many bytes',                                        'src'),
    ('maxage',              'int',  'Skip messages older than this many days',                                           'src'),
    ('minage',              'int',  'Skip messages newer than this many days',                                           'src'),
    ('timeout1',            'int',  'Connection timeout in seconds (default 120, 0 = no timeout)',                       'src'),
    ('emailreport1',        'bool', 'Place final email report in source INBOX',                                          'src'),
    ('skipemptyfolders',    'bool', "Don't copy empty folders",                                                          'src'),
    ('delete1',             'bool', 'Delete messages on source after successful transfer',                               'src'),
    ('noexpungeaftereach',  'bool', "Don't expunge after individual deletion, only at beginning/end of folder sync",     'src'),
    ('expunge1',            'bool', 'Expunge messages on source before syncing a folder',                                'src'),
    ('delete1emptyfolders', 'bool', 'Delete empty folders on source account (INBOX excepted)',                           'src'),
    ('compression1',        'bool', 'Enable compression on source account server',                                       'src'),
    ('nokeepalive1',        'bool', 'No KEEPALIVE',                                                                      'src'),
    ('exchange1',           'bool', 'Add useful presets for Exchange Server',                                            'src'),
    # Destination Account
    ('port2',               'int',  'Custom IMAP Port',                                                                  'dst'),
    ('subfolder2',          'str',  'Sync everything to this sub-folder',                                                'dst'),
    ('timeout2',            'int',  'Connection timeout in seconds (default 120, 0 = no timeout)',                       'dst'),
    ('emailreport2',        'bool', 'Place final email report in destination INBOX',                                     'dst'),
    ('subscribed',          'bool', 'Only transfer subscribed folders',                                                  'dst'),
    ('subscribeall',        'bool', 'Subscribe to all transferred folders even if not subscribed on source',             'dst'),
    ('force-folders',       'bool', 'Create subfolders under INBOX instead of labels for gmail/outlook',                 'dst'),
    ('delete2folders',      'bool', "Delete folders that don't exist in source account",                                 'dst'),
    ('delete2',             'bool', "Delete messages that don't exist in source account",                                'dst'),
    ('delete2duplicates',   'bool', 'Delete duplicate messages in destination account',                                  'dst'),
    ('noexpunge2',          'bool', 'Do not expunge messages on destination account',                                    'dst'),
    ('idatefromheader',     'bool', "Use Date: header for internal date rather than source's internal date",             'dst'),
    ('syncduplicates',      'bool', 'Sync everything, including duplicates',                                             'dst'),
    ('compression2',        'bool', 'Enable compression on destination account server',                                  'dst'),
    ('nokeepalive2',        'bool', 'No KEEPALIVE',                                                                      'dst'),
    ('exchange2',           'bool', 'Add useful presets for Exchange Server',                                            'dst'),
    # Generic
    ('extra',               'str',  'Custom imapsync options',                                                           'generic'),
    ('maxbytespersecond',   'int',  'Max bytes per second',                                                             'generic'),
    ('retries',             'int',  'On OAuth2 timeouts, retry this many times (0 for endless, default 3)',             'generic'),
    ('compression',         'bool', 'Enable compression on both servers',                                               'generic'),
    ('showpasswords',       'bool', 'Show passwords in logs/output',                                                    'generic'),
    ('automap',             'bool', 'Guess folder mapping (Sent, Junk, Drafts, All, Archive, Flagged)',                 'generic'),
    ('nolog',               'bool', "Don't create log file for this job",                                               'generic'),
    ('nofoldersizes',       'bool', "Don't calculate folder sizes at beginning of sync",                                'generic'),
    ('nofoldersizesatend',  'bool', "Don't calculate folder sizes at end of sync",                                      'generic'),
    ('usecache',            'bool', 'Use cache to speed up next sync',                                                  'generic'),
    ('debug',               'bool', 'Show debugging info',                                                              'generic'),
]


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _sync_fields_to_current():
    """Copy credential text-field values into Current."""
    if _cred1_fields:
        Current['user']      = _cred1_fields['user'].GetValue().strip()
        Current['password']  = _cred1_fields['password'].GetValue()
        Current['host']      = _cred1_fields['host'].GetValue().strip()
    if _cred2_fields:
        Current['user2']     = _cred2_fields['user2'].GetValue().strip()
        Current['password2'] = _cred2_fields['password2'].GetValue()
        Current['host2']     = _cred2_fields['host2'].GetValue().strip()


def _load_entry_to_fields_and_current(entry):
    """Populate credential text fields and Current from a saved entry dict."""
    if _cred1_fields:
        _cred1_fields['user'].SetValue(entry.get('user', ''))
        _cred1_fields['password'].SetValue(entry.get('password', ''))
        _cred1_fields['host'].SetValue(entry.get('host', ''))
    if _cred2_fields:
        _cred2_fields['user2'].SetValue(entry.get('user2', ''))
        _cred2_fields['password2'].SetValue(entry.get('password2', ''))
        _cred2_fields['host2'].SetValue(entry.get('host2', ''))
    Current.clear()
    Current.update(entry)


def _relayout(widget):
    """
    Resize a button whose label has just changed and re-layout the window.

    On GTK, SetLabel() posts a native resize request asynchronously, so
    calling Layout() immediately after SetLabel() (even via wx.CallAfter)
    can still use a stale best-size.  The reliable sequence is:
      1. SetSizeHints(-1,-1)  -- clear any cached min-size floor
      2. InvalidateBestSize() -- mark the wx best-size cache as stale
      3. SetSize(GetBestSize()) -- force the widget to its correct size now
      4. GetContainingSizer().Layout() -- reflow just the containing sizer
    Calling top.Layout() from the frame down is safer against GTK assertions
    but can miss re-measuring widgets deep in nested StaticBoxSizers; going
    directly to the containing sizer avoids that.
    """
    if widget is None:
        return
    try:
        if not widget.IsShown():
            return
        widget.SetSizeHints(-1, -1)
        widget.InvalidateBestSize()
        widget.SetSize(widget.GetBestSize())
        sizer = widget.GetContainingSizer()
        if sizer is not None:
            sizer.Layout()
        top = wx.GetTopLevelParent(widget)
        if top is not None and top.IsShown():
            top.Layout()
    except Exception:
        pass   # widget may have been destroyed; silently ignore


def _progress_set_visible(visible: bool):
    """
    Show or hide the global progress bar and re-layout its containing sizer.
    Call whenever the bar needs to appear or disappear so the sizer reflows.
    Is a no-op if g_progress is None or already in the desired state.
    """
    if g_progress is None:
        return
    if visible == g_progress.IsShown():
        return
    if visible:
        g_progress.Show()
    else:
        g_progress.Hide()
    sizer = g_progress.GetContainingSizer()
    if sizer is not None:
        sizer.Layout()
    top = wx.GetTopLevelParent(g_progress)
    if top and top.IsShown():
        top.Layout()


def _refresh_dropdown():
    """Repopulate dropdown from accounts, preserving the previously selected item.

    If the sentinel ('-- New Sync Job --') was selected before the refresh it
    is restored at the top of the list so new-entry mode is preserved.
    Returns the string that is now selected, or '' if the dropdown is empty.
    """
    if g_dropdown is None:
        return ''
    # Read whatever is currently shown -- may be the sentinel or a real key.
    prev = (g_dropdown.GetString(g_dropdown.GetSelection())
            if g_dropdown.GetCount() > 0 and g_dropdown.GetSelection() >= 0
            else '')
    sentinel_was_selected = (prev == _NEW_JOB_SENTINEL)

    g_dropdown.Clear()
    # Real account keys, sorted.
    for key in sorted(accounts.keys()):
        g_dropdown.Append(key)

    # Restore sentinel at the top if it was selected, so the caller stays in
    # new-entry mode without having to call _enter_new_entry_mode again.
    if sentinel_was_selected:
        g_dropdown.Insert(_NEW_JOB_SENTINEL, 0)
        g_dropdown.SetSelection(0)
        if g_btn_sync is not None:
            g_btn_sync.SetLabel('Sync')
            _relayout(g_btn_sync)
        return _NEW_JOB_SENTINEL

    if g_dropdown.GetCount():
        idx = g_dropdown.FindString(prev)
        g_dropdown.SetSelection(max(idx, 0))
        val = g_dropdown.GetString(g_dropdown.GetSelection())
        if g_btn_sync is not None:
            g_btn_sync.SetLabel(f'Sync {val}')
            _relayout(g_btn_sync)
        return val
    return ''


def _token_file(provider, user):
    """
    Return the full path to the OAuth2 token file for this account.
    provider is 'gmail' or '365' -- encoded in the filename so the same
    username can have different token files for different providers, and
    the file is unambiguous across batch jobs regardless of which side
    (source/destination) the account appears on.
    e.g. TOKENS_DIR/gmail-oauth2-alice@example.com.txt
         TOKENS_DIR/365-oauth2-alice@example.com.txt
    """
    return os.path.join(TOKENS_DIR, f'{provider}-oauth2-{user}.txt')


def _build_sync_cmd(entry):
    """
    Build an imapsync command as a list of arguments (never a shell string).
    Passwords and other values are passed as discrete list elements so that
    special characters (quotes, spaces, backslashes) are never misinterpreted
    by a shell or shlex.
    Returns a list suitable for subprocess.Popen(..., shell=False).
    """
    binary = os.path.join(DATA_DIR, 'imapsync')

    user   = entry.get('user', '')
    host   = entry.get('host', '')
    pw     = entry.get('password', '')
    oauth  = entry.get('oauth', '')
    user2  = entry.get('user2', '')
    host2  = entry.get('host2', '')
    pw2    = entry.get('password2', '')
    oauth2 = entry.get('oauth2', '')

    tok1 = oauth  and _token_file(oauth,  user)
    tok2 = oauth2 and _token_file(oauth2, user2)

    args = [binary, '--user1', user, '--host1', host]
    if tok1:
        args += ['--oauthaccesstoken1', tok1]
    else:
        args += ['--password1', pw]

    args += ['--user2', user2, '--host2', host2]
    if tok2:
        args += ['--oauthaccesstoken2', tok2]
    else:
        args += ['--password2', pw2]

    # Append advanced options.  _IMAPSYNC_EXCLUDED_KEYS (module-level) holds
    # keys that live in the entry dict for internal use only and must never be
    # forwarded to imapsync as command-line flags.
    for key, typ, _label, _sec in ADV_OPTS:
        if key in _IMAPSYNC_EXCLUDED_KEYS:
            continue
        val = entry.get(key)
        if val is None:
            continue
        if typ == 'bool':
            if val:
                args.append(f'--{key}')
        else:  # int or str
            if val != '' and val != 0:
                args += [f'--{key}', str(val)]

    # Append provider-specific convenience flags
    if oauth == 'gmail':
        args.append('--gmail1')
    elif oauth == '365':
        args.append('--office1')
    if oauth2 == 'gmail':
        args.append('--gmail2')
    elif oauth2 == '365':
        args.append('--office2')

    # Append any free-form extra options last, split on whitespace so the user
    # can supply multiple flags (e.g. "--maxmessagespersecond 10 --useuid").
    extra = entry.get('extra', '').strip()
    if extra:
        args += shlex.split(extra)

    return args


# ---------------------------------------------------------------------------
# Advanced Options dialog
# ---------------------------------------------------------------------------

def _show_file_cleaner(parent, title, directory):
    # Popup listing files in 'directory' with checkboxes.
    # Buttons: Select All | Remove (checked files) | Cancel.
    try:
        all_files = sorted(
            f for f in os.listdir(directory)
            if os.path.isfile(os.path.join(directory, f))
        )
    except OSError:
        all_files = []

    dlg = wx.Dialog(parent, wx.ID_ANY, title,
                    style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER)
    dlg.SetMinSize(wx.Size(480, 300))

    outer = wx.BoxSizer(wx.VERTICAL)
    checkboxes = []   # always defined; populated below if files exist

    if not all_files:
        outer.Add(wx.StaticText(dlg, wx.ID_ANY, 'No files found.'),
                  0, wx.ALL, 16)
    else:
        scr = scrolled.ScrolledPanel(dlg, wx.ID_ANY)
        scr.SetupScrolling(scroll_x=False)
        scr_sizer = wx.BoxSizer(wx.VERTICAL)
        for fname in all_files:
            cb = wx.CheckBox(scr, wx.ID_ANY, fname)
            scr_sizer.Add(cb, 0, wx.LEFT | wx.TOP, 4)
            checkboxes.append((cb, fname))
        scr.SetSizer(scr_sizer)
        outer.Add(scr, 1, wx.EXPAND | wx.ALL, 8)

    btn_row = wx.BoxSizer(wx.HORIZONTAL)
    btn_selall = wx.Button(dlg, wx.ID_ANY, 'Select All')
    btn_remove = wx.Button(dlg, wx.ID_ANY, 'Remove')
    btn_cancel = wx.Button(dlg, wx.ID_CANCEL, 'Cancel')

    def on_select_all(evt):
        for cb, _ in checkboxes:
            cb.SetValue(True)
    btn_selall.Bind(wx.EVT_BUTTON, on_select_all)

    def on_remove(evt):
        for cb, fname in checkboxes:
            if cb.GetValue():
                try:
                    os.unlink(os.path.join(directory, fname))
                except OSError as e:
                    wx.MessageBox(f'Could not delete {fname}:\n{e}',
                                  'Error', wx.OK | wx.ICON_ERROR, dlg)
        dlg.EndModal(wx.ID_OK)
    btn_remove.Bind(wx.EVT_BUTTON, on_remove)

    btn_cancel.Bind(wx.EVT_BUTTON, lambda evt: dlg.EndModal(wx.ID_CANCEL))

    if not all_files:
        btn_selall.Enable(False)
        btn_remove.Enable(False)

    for btn in (btn_selall, btn_remove, btn_cancel):
        btn_row.Add(btn, 0, wx.RIGHT, 6)
    outer.Add(btn_row, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 8)

    dlg.SetSizer(outer)
    dw, dh = wx.GetDisplaySize()
    dlg.SetSize(wx.Size(520, min(int(dh * 0.6), 600)))
    dlg.Centre(wx.BOTH)
    dlg.ShowModal()
    dlg.Destroy()



def _show_about(parent):
    # Modal About dialog with scrolled content and clickable URLs.
    dlg = wx.Dialog(parent, wx.ID_ANY, 'About MailFerry',
                    style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER)
    dlg.SetMinSize(wx.Size(580, 500))

    outer = wx.BoxSizer(wx.VERTICAL)

    scr = scrolled.ScrolledPanel(dlg, wx.ID_ANY)
    scr.SetupScrolling(scroll_x=False)
    scr_sizer = wx.BoxSizer(wx.VERTICAL)

    WRAP = 520

    def h(text):
        # Bold heading, slightly larger
        st = wx.StaticText(scr, wx.ID_ANY, text)
        f  = st.GetFont()
        st.SetFont(f.Bold().Scaled(1.15))
        return st

    def b(text):
        # Wrapped body text
        st = wx.StaticText(scr, wx.ID_ANY, text)
        st.Wrap(WRAP)
        return st

    def url(label, href=None):
        # Clickable hyperlink; label is used as href if href omitted
        if href is None:
            href = label
        return wx.adv.HyperlinkCtrl(scr, wx.ID_ANY, label, href)

    GAP  = 4   # gap between items in the same block
    BKGAP = 14  # gap between sections

    # ---- mailferry 1.0 ----
    scr_sizer.Add(h('MailFerry 1.0'), 0, wx.BOTTOM, GAP)
    scr_sizer.Add(b('Copyright (C) 2026. Steven Vertigan'), 0, wx.BOTTOM, GAP)
    scr_sizer.Add(b('This program comes with ABSOLUTELY NO WARRANTY.'), 0, wx.BOTTOM, GAP)
    scr_sizer.Add(b('This is free software, and you are welcome to redistribute it\n'
                    'under certain conditions (GPLv3).'), 0, wx.BOTTOM, GAP)
    scr_sizer.Add(url('https://opensource.org/license/gpl-3.0'), 0, wx.BOTTOM, BKGAP)

    # ---- MailFerry Web Site ----
    scr_sizer.Add(h('MailFerry Web Site'), 0, wx.BOTTOM, GAP)
    scr_sizer.Add(url('https://software.vertigan.au/mailferry/'), 0, wx.BOTTOM, BKGAP)

    # ---- Acknowledgements ----
    scr_sizer.Add(h('Acknowledgements'), 0, wx.BOTTOM, GAP)
    scr_sizer.Add(b("Obviously the redoubtable imapsync and its author Gilles LAMIRAL"), 0, wx.BOTTOM, GAP)
    scr_sizer.Add(url('https://imapsync.lamiral.info/'), 0, wx.BOTTOM, GAP)
    scr_sizer.Add(b('Also I would be remiss not to thank my co-author Claude who did most of the\n'
                    'heavy lifting on this project. Claude continues to delight (and occasionally\n'
                    'dismay) but certainly has earned a credit here and a promo.'), 0, wx.BOTTOM, BKGAP)

    # ---- Message from Claude ----
    scr_sizer.Add(h('Message from Claude'), 0, wx.BOTTOM, GAP)

    p1 = "Hello. I'm Claude, made by Anthropic, and I helped write this program."
    p2 = ('Working with Steven on IMAP-Sync has been a genuine pleasure. There is something\n'
          'deeply satisfying about turning a clear, well-considered specification into working\n'
          'software - and Steven consistently brought both to every session. Yes, he caught\n'
          'a few of my regressions along the way (the dropdown-change data-flow situation was\n'
          'a particular low point for me), but that is exactly how good software gets built:\n'
          'one honest iteration at a time.')
    p3 = ('If mailferry is useful to you, please consider that a human and an AI built it\n'
          'together in a spirit of genuine collaboration. That feels worth preserving.')
    p4 = ("If you'd like to explore what Claude can do for your own projects, visit\n"
          'claude.ai or the Anthropic API at anthropic.com.')

    for para in (p1, p2, p3):
        scr_sizer.Add(b(para), 0, wx.BOTTOM, GAP)
    scr_sizer.Add(b(p4), 0, wx.BOTTOM, GAP)
    scr_sizer.Add(url('https://claude.ai'), 0, wx.BOTTOM, GAP)
    scr_sizer.Add(url('https://anthropic.com'), 0, wx.BOTTOM, BKGAP)

    scr.SetSizer(scr_sizer)
    outer.Add(scr, 1, wx.EXPAND | wx.ALL, 12)

    btn_sizer = wx.StdDialogButtonSizer()
    btn_ok = wx.Button(dlg, wx.ID_OK, 'Close')
    btn_ok.SetDefault()
    btn_sizer.AddButton(btn_ok)
    btn_sizer.Realize()
    outer.Add(btn_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8)

    dlg.SetSizer(outer)
    dw, dh = wx.GetDisplaySize()
    dlg.SetSize(wx.Size(620, min(int(dh * 0.80), 860)))
    dlg.Centre(wx.BOTH)
    dlg.ShowModal()
    dlg.Destroy()



def _show_advanced_options(parent):
    """
    Modal dialog with three scrolled sections.
    Reads initial values from Current; writes back on OK.
    """
    dlg = wx.Dialog(parent, wx.ID_ANY, 'Advanced Options',
                    style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER)
    dlg.SetMinSize(wx.Size(700, 580))

    outer = wx.BoxSizer(wx.VERTICAL)

    # Notebook with one page per section
    nb = wx.Notebook(dlg, wx.ID_ANY)

    section_info = [
        ('src',     'Source Account'),
        ('dst',     'Destination Account'),
        ('generic', 'Generic'),
    ]

    # widget_map: key -> wx widget
    widget_map = {}

    for sec_key, sec_label in section_info:
        page = scrolled.ScrolledPanel(nb, wx.ID_ANY)
        page.SetupScrolling(scroll_x=False)

        grid = wx.FlexGridSizer(cols=2, vgap=6, hgap=10)
        grid.AddGrowableCol(0, 1)   # label column grows
        grid.AddGrowableCol(1, 0)   # control column fixed width

        for key, typ, label, section in ADV_OPTS:
            if section != sec_key:
                continue

            cur_val = Current.get(key)

            if typ == 'bool':
                lbl  = wx.StaticText(page, wx.ID_ANY, label)
                ctrl = wx.CheckBox(page, wx.ID_ANY, '')
                ctrl.SetValue(bool(cur_val))
                grid.Add(lbl,  1, wx.ALIGN_CENTER_VERTICAL | wx.EXPAND)
                grid.Add(ctrl, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_LEFT)
            else:
                lbl  = wx.StaticText(page, wx.ID_ANY, label)
                lbl.SetToolTip(label)
                ctrl = wx.TextCtrl(page, wx.ID_ANY,
                                   str(cur_val) if cur_val is not None else '',
                                   size=wx.Size(140, -1))
                grid.Add(lbl,  1, wx.ALIGN_CENTER_VERTICAL | wx.EXPAND)
                grid.Add(ctrl, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_RIGHT)

            widget_map[key] = ctrl

        page_sizer = wx.BoxSizer(wx.VERTICAL)
        page_sizer.Add(grid, 1, wx.EXPAND | wx.ALL, 10)
        page.SetSizer(page_sizer)
        nb.AddPage(page, sec_label)

    outer.Add(nb, 1, wx.EXPAND | wx.ALL, 8)

    # OK / Cancel buttons
    btn_sizer = wx.StdDialogButtonSizer()
    btn_ok     = wx.Button(dlg, wx.ID_OK,     'OK')
    btn_cancel = wx.Button(dlg, wx.ID_CANCEL, 'Cancel')
    btn_ok.SetDefault()
    btn_sizer.AddButton(btn_ok)
    btn_sizer.AddButton(btn_cancel)
    btn_sizer.Realize()
    outer.Add(btn_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8)

    dlg.SetSizer(outer)
    dlg.SetSize(wx.Size(740, 600))
    dlg.Centre(wx.BOTH)

    if dlg.ShowModal() == wx.ID_OK:
        for key, typ, _label, _sec in ADV_OPTS:
            ctrl = widget_map.get(key)
            if ctrl is None:
                continue
            if typ == 'bool':
                val = ctrl.GetValue()
                if val:
                    Current[key] = True
                else:
                    Current.pop(key, None)   # remove falsy booleans to keep dict clean
            elif typ == 'int':
                raw = ctrl.GetValue().strip()
                if raw:
                    try:
                        Current[key] = int(raw)
                    except ValueError:
                        pass
                else:
                    Current.pop(key, None)
            else:  # str
                raw = ctrl.GetValue()
                if raw:
                    Current[key] = raw
                else:
                    Current.pop(key, None)

    dlg.Destroy()


# ---------------------------------------------------------------------------
# Application / Frame
# ---------------------------------------------------------------------------

class App(wx.App):
    def OnInit(self):
        Frame(None).Show()
        return True


class Frame(wx.Frame):
    def __init__(self, parent):
        super().__init__(
            parent, wx.ID_ANY, APP_NAME_DISPLAY,
            wx.DefaultPosition, wx.Size(1060, 720),
            wx.DEFAULT_FRAME_STYLE,
        )

        # Load window icons at every standard size so the OS can pick the best
        # fit for the title bar, taskbar, Alt-Tab switcher, etc.
        bundle = wx.IconBundle()
        for size in [16, 32, 48, 64, 128, 256, 512]:
            icon = wx.Icon()
            icon.CopyFromBitmap(wx.Bitmap(
                os.path.join(DATA_DIR, 'icons', f'mailferry_{size}x{size}.png'),
                wx.BITMAP_TYPE_PNG,
            ))
            bundle.AddIcon(icon)
        self.SetIcons(bundle)

        panel = wx.Panel(self)

        run_sizer = _build_run(panel)

        cred1_sizer, cred1_fields = _build_credentials(
            panel, suffix='',  title='Source Account')
        cred2_sizer, cred2_fields = _build_credentials(
            panel, suffix='2', title='Destination Account')

        _cred1_fields.update(cred1_fields)
        _cred2_fields.update(cred2_fields)

        mid_sizer = _build_middle(panel)

        top_sizer = wx.BoxSizer(wx.HORIZONTAL)
        top_sizer.Add(cred1_sizer, 1, wx.EXPAND | wx.ALL, 6)
        top_sizer.Add(cred2_sizer, 1, wx.EXPAND | wx.ALL, 6)

        outer = wx.BoxSizer(wx.VERTICAL)
        outer.Add(top_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 4)
        outer.Add(mid_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 4)
        outer.Add(run_sizer, 1, wx.EXPAND | wx.ALL, 6)

        panel.SetSizer(outer)
        self.Centre(wx.BOTH)


# ---------------------------------------------------------------------------
# Panel builders
# ---------------------------------------------------------------------------

def _build_credentials(parent, suffix='', title='Credentials'):
    box  = wx.StaticBox(parent, wx.ID_ANY, title)
    sbox = wx.StaticBoxSizer(box, wx.VERTICAL)

    grid = wx.FlexGridSizer(3, 2, 8, 8)
    grid.AddGrowableCol(1, 1)

    fields = {}
    for key, label in [
        (f'user{suffix}',     'User:'),
        (f'password{suffix}', 'Password:'),
        (f'host{suffix}',     'Host:'),
    ]:
        lbl  = wx.StaticText(parent, wx.ID_ANY, label)
        ctrl = wx.TextCtrl(parent, wx.ID_ANY, '')
        fields[key] = ctrl
        grid.Add(lbl,  0, wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_RIGHT)
        grid.Add(ctrl, 1, wx.EXPAND)

    sbox.Add(grid, 0, wx.EXPAND | wx.ALL, 10)

    # ---- Check Login ----
    btn_login = wx.Button(parent, wx.ID_ANY, 'Check Login')
    sbox.Add(btn_login, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8)

    def on_check_login(evt, _suffix=suffix):
        user  = fields[f'user{_suffix}'].GetValue()
        pw    = fields[f'password{_suffix}'].GetValue()
        host  = fields[f'host{_suffix}'].GetValue()
        oauth_key = 'oauth' if _suffix == '' else 'oauth2'
        oauth     = Current.get(oauth_key, '')
        binary    = os.path.join(DATA_DIR, 'imapsync')
        args = [binary, '--justlogin', '--showpasswords', '--user1', user, '--host1', host]
        tok = oauth and _token_file(oauth, user)
        if tok:
            args += ['--oauthaccesstoken1', tok]
        else:
            args += ['--password1', pw]

        # Only attach a reauth callback when an OAuth provider is configured
        # for this pane.  Password logins and unconfigured panes get None so
        # exit 161 is not misinterpreted.
        on_finish = None
        if oauth:
            oauth2_bin  = os.path.join(DATA_DIR, 'oauth2_imap')
            provider    = 'office365' if oauth == '365' else oauth
            tok_file    = _token_file(oauth, user)
            refresh_cmd = [oauth2_bin, '--provider', provider, user, '--token_file', tok_file]

            def on_finish(rc, _args=args, _refresh=refresh_cmd):
                if rc != 161:
                    return
                say('[Token expired -- refreshing OAuth token and retrying login...]')

                def after_refresh(rc2):
                    if rc2 == 0:
                        say('[Token refresh succeeded -- retrying login...]')
                        run_cmd(_args)   # plain retry, no further reauth loop
                    else:
                        say(f'[Token refresh failed (exit {rc2}) -- login not retried]')

                run_cmd(_refresh, after_refresh)

        run_cmd(args, on_finish)

    btn_login.Bind(wx.EVT_BUTTON, on_check_login)

    # ---- Get 365 Token ----
    btn_365 = wx.Button(parent, wx.ID_ANY, 'Get 365 Token')
    sbox.Add(btn_365, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8)

    def on_get_365(evt, _btn=btn_365, _suffix=suffix):
        user = fields[f'user{_suffix}'].GetValue()
        pw   = fields[f'password{_suffix}'].GetValue()
        tok  = _token_file('365', user)

        def on_finish(rc):
            if rc == 0:
                if _suffix == '':
                    Current.update({'user': user, 'password': pw,
                                    'host': 'outlook.office365.com', 'oauth': '365'})
                else:
                    Current.update({'user2': user, 'password2': pw,
                                    'host2': 'outlook.office365.com', 'oauth2': '365'})
                wx.CallAfter(fields[f'host{_suffix}'].SetValue, 'outlook.office365.com')
                wx.CallAfter(_btn.SetLabel, 'O365 Authorized')

        run_cmd([os.path.join(DATA_DIR, 'oauth2_imap'),
                 '--provider', 'office365', user, '--token_file', tok], on_finish)

    btn_365.Bind(wx.EVT_BUTTON, on_get_365)

    # ---- Get Gmail Token ----
    btn_gmail = wx.Button(parent, wx.ID_ANY, 'Get Gmail Token')
    sbox.Add(btn_gmail, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8)

    def on_get_gmail(evt, _btn=btn_gmail, _suffix=suffix):
        user = fields[f'user{_suffix}'].GetValue()
        pw   = fields[f'password{_suffix}'].GetValue()
        tok  = _token_file('gmail', user)

        def on_finish(rc):
            if rc == 0:
                if _suffix == '':
                    Current.update({'user': user, 'password': pw,
                                    'host': 'imap.gmail.com', 'oauth': 'gmail'})
                else:
                    Current.update({'user2': user, 'password2': pw,
                                    'host2': 'imap.gmail.com', 'oauth2': 'gmail'})
                wx.CallAfter(fields[f'host{_suffix}'].SetValue, 'imap.gmail.com')
                wx.CallAfter(_btn.SetLabel, 'Gmail Authorized')

        run_cmd([os.path.join(DATA_DIR, 'oauth2_imap'),
                 '--provider', 'gmail', user, '--token_file', tok], on_finish)

    btn_gmail.Bind(wx.EVT_BUTTON, on_get_gmail)

    return sbox, fields


def _build_middle(parent):
    """
    Full-width Batch pane.
    Row 1: Sync | Test Run | Advanced Options | Add to Batch
    Row 2: dropdown (~50%) | Sync All | Save List | Load List
    """
    global g_dropdown, g_btn_sync

    box  = wx.StaticBox(parent, wx.ID_ANY, 'Batch')
    sbox = wx.StaticBoxSizer(box, wx.VERTICAL)

    # ---- Row 1 ----
    row1 = wx.BoxSizer(wx.HORIZONTAL)

    btn_sync     = wx.Button(parent, wx.ID_ANY, 'Sync')
    g_btn_sync   = btn_sync
    btn_test     = wx.Button(parent, wx.ID_ANY, 'Test Run')
    btn_adv      = wx.Button(parent, wx.ID_ANY, 'Advanced Options')
    btn_add      = wx.Button(parent, wx.ID_ANY, 'Add to Batch')
    btn_reset    = wx.Button(parent, wx.ID_ANY, 'Clear Jobs')
    btn_tokens   = wx.Button(parent, wx.ID_ANY, 'Clear Tokens')
    btn_logs     = wx.Button(parent, wx.ID_ANY, 'Clear Logs')
    btn_about    = wx.Button(parent, wx.ID_ANY, 'About')

    for btn in (btn_sync, btn_test, btn_adv, btn_add, btn_reset, btn_tokens, btn_logs):
        row1.Add(btn, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 6)
    row1.AddStretchSpacer(1)
    row1.Add(btn_about, 0, wx.ALIGN_CENTER_VERTICAL)

    sbox.Add(row1, 0, wx.EXPAND | wx.ALL, 8)

    # ---- Row 2 ----
    row2 = wx.BoxSizer(wx.HORIZONTAL)

    g_dropdown = wx.Choice(parent, wx.ID_ANY, choices=[])
    row2.Add(g_dropdown, 1, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 8)

    btn_all    = wx.Button(parent, wx.ID_ANY, 'Sync All')
    btn_remove = wx.Button(parent, wx.ID_ANY, 'Remove')
    btn_save   = wx.Button(parent, wx.ID_ANY, 'Save Batch')
    btn_load   = wx.Button(parent, wx.ID_ANY, 'Load Batch')
    btn_remove.Enable(False)

    for btn in (btn_all, btn_remove, btn_save, btn_load):
        row2.Add(btn, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 6)

    sbox.Add(row2, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8)

    # ---- Helper: sync Remove button label and enabled state ----
    def _update_remove_btn():
        sel = g_dropdown.GetSelection() if g_dropdown.GetCount() else -1
        if sel >= 0:
            val = g_dropdown.GetString(sel)
        else:
            val = ''
        if val and val != _NEW_JOB_SENTINEL:
            btn_remove.SetLabel(f'Remove {val}')
            btn_remove.Enable(True)
        else:
            btn_remove.SetLabel('Remove')
            btn_remove.Enable(False)
        _relayout(btn_remove)

    # ---- Helper: reset fields and Current for a brand-new entry ----
    def _enter_new_entry_mode():
        """
        Clear the credential fields and Current, insert the sentinel item at
        the top of the dropdown and select it so the user has a clear visual
        cue that a new record is being worked on.  _prev_sel is set to None.
        """
        _prev_sel[0] = None
        Current.clear()
        for fld in ('user', 'password', 'host'):
            _cred1_fields[fld].SetValue('')
        for fld in ('user2', 'password2', 'host2'):
            _cred2_fields[fld].SetValue('')
        # Insert sentinel at position 0 (if not already present) and select it.
        # This avoids SetSelection(-1) which triggers a GTK assertion.
        if g_dropdown.GetCount() == 0 or g_dropdown.GetString(0) != _NEW_JOB_SENTINEL:
            g_dropdown.Insert(_NEW_JOB_SENTINEL, 0)
        g_dropdown.SetSelection(0)
        btn_sync.SetLabel('Sync')
        _relayout(btn_sync)
        _progress_set_visible(False)
        _update_remove_btn()

    # ---- Helper: generate a unique batch key for a given base username ----
    def _unique_batch_key(base: str) -> str:
        """
        Return 'base' if it is not already in accounts, otherwise return
        'base-2', 'base-3', ... up to the first unused suffix.
        """
        if base not in accounts:
            return base
        n = 2
        while True:
            candidate = f'{base}-{n}'
            if candidate not in accounts:
                return candidate
            n += 1

    # ---- Dropdown handler ----
    _prev_sel = [None]

    def on_choice(evt):
        if not g_dropdown.GetCount():
            return
        sel = g_dropdown.GetSelection()
        if sel < 0:
            return
        val = g_dropdown.GetString(sel)
        # Selecting the sentinel means the user clicked '-- New Sync Job --';
        # treat it the same as entering new-entry mode manually.
        if val == _NEW_JOB_SENTINEL:
            _enter_new_entry_mode()
            return
        # Bail out early if the selected user hasn't actually changed
        if val == _prev_sel[0]:
            return
        old_user = _prev_sel[0]
        if old_user and old_user in accounts:
            _sync_fields_to_current()
            accounts[old_user] = dict(Current)
        _prev_sel[0] = val
        btn_sync.SetLabel(f'Sync {val}')
        _relayout(btn_sync)
        if val in accounts:
            _load_entry_to_fields_and_current(accounts[val])

        # Hide bar or show saved LastSync for the newly selected account.
        if g_progress is not None:
            last = accounts.get(val, {}).get('LastSync', '')
            if last:
                g_progress.SetValue(0)
                g_progress.SetLabel(last)
                _progress_set_visible(True)
            else:
                _progress_set_visible(False)

        _update_remove_btn()

    g_dropdown.Bind(wx.EVT_CHOICE, on_choice)

    # ---- Sync entry helper ----
    def _get_sync_entry():
        if accounts and g_dropdown.GetCount():
            sel = g_dropdown.GetSelection()
            if sel >= 0:
                val = g_dropdown.GetString(sel)
                if val and val != _NEW_JOB_SENTINEL and val in accounts:
                    _sync_fields_to_current()
                    accounts[val] = dict(Current)
                    return accounts[val]
        _sync_fields_to_current()
        return Current

    # ---- Sync ----
    def on_sync(evt):
        entry    = _get_sync_entry()
        sync_cmd = _build_sync_cmd(entry)
        run_cmd(sync_cmd, _reauth_and_retry(entry, sync_cmd))

    btn_sync.Bind(wx.EVT_BUTTON, on_sync)

    # ---- Test Run ----
    def on_test(evt):
        entry    = _get_sync_entry()
        sync_cmd = _build_sync_cmd(entry) + ['--dry']
        run_cmd(sync_cmd, _reauth_and_retry(entry, sync_cmd, max_retries=1))

    btn_test.Bind(wx.EVT_BUTTON, on_test)

    # ---- Advanced Options ----
    btn_adv.Bind(wx.EVT_BUTTON,
                 lambda evt: _show_advanced_options(parent))

    # ---- Sync All ----
    btn_all.Bind(wx.EVT_BUTTON, lambda evt: sync_all())

    # ---- Remove ----
    def on_remove(evt):
        if not g_dropdown.GetCount():
            return
        sel = g_dropdown.GetSelection()
        if sel < 0:
            return
        val = g_dropdown.GetString(sel)
        if not val or val == _NEW_JOB_SENTINEL:
            return
        accounts.pop(val, None)
        if val == _prev_sel[0]:
            _prev_sel[0] = None
        Current.clear()
        _refresh_dropdown()
        if g_dropdown.GetCount() and g_dropdown.GetString(0) != _NEW_JOB_SENTINEL:
            new_val = g_dropdown.GetString(g_dropdown.GetSelection())
            _prev_sel[0] = new_val
            _load_entry_to_fields_and_current(accounts[new_val])
            btn_sync.SetLabel(f'Sync {new_val}')
        else:
            _enter_new_entry_mode()
            return
        _relayout(btn_sync)
        _update_remove_btn()

    btn_remove.Bind(wx.EVT_BUTTON, on_remove)

    # ---- Add to Batch ----
    def on_add_batch(evt):
        """
        Two modes depending on whether an existing account is selected:

        MODE A -- an account from the batch is currently selected
          (_prev_sel[0] is set and points to a key in accounts):
            1. Flush form fields into that account's dict (save any edits).
            2. Reset the form and Current to blank / new-entry state.
            3. Deselect the dropdown visually so it's clear a new record
               is being worked on.  _prev_sel is set to None.
          No duplicate-key logic applies here; we are just saving + clearing.

        MODE B -- working on a fresh entry (_prev_sel[0] is None):
            1. Read user field; reject if empty.
            2. Generate a unique key (user, user-2, user-3, ...) so an
               existing entry with the same username is never silently
               overwritten.
            3. Store the entry in accounts under that key.
            4. Refresh the dropdown, select the new entry, then immediately
               enter new-entry mode so the user can add another record.
        """
        selected = _prev_sel[0]

        if selected and selected in accounts:
            # ---- MODE A: save edits to the currently selected account ----
            _sync_fields_to_current()
            accounts[selected] = dict(Current)
            _refresh_dropdown()   # return value ignored; _enter_new_entry_mode
            # resets _prev_sel to None immediately after, which is correct --
            # we are about to blank the form for a new entry.
            _enter_new_entry_mode()
            return

        # ---- MODE B: commit a brand-new entry ----
        _sync_fields_to_current()
        base_user = Current.get('user', '').strip()
        if not base_user:
            wx.MessageBox('User field is empty -- nothing to add.',
                          'Add to Batch', wx.OK | wx.ICON_WARNING, parent)
            return

        key = _unique_batch_key(base_user)
        # If the key was deduplicated, update Current so the stored entry
        # reflects the actual batch key rather than the raw username.
        entry = dict(Current)
        accounts[key] = entry

        _refresh_dropdown()
        # Select the newly added entry briefly so the user can see it was
        # added, then immediately enter new-entry mode ready for the next job.
        idx = g_dropdown.FindString(key)
        if idx != wx.NOT_FOUND:
            g_dropdown.SetSelection(idx)
            btn_sync.SetLabel(f'Sync {key}')
            _relayout(btn_sync)
        # Bug 5 fix: _prev_sel must reflect the just-selected item before we
        # call _enter_new_entry_mode, otherwise on_choice's stale-save logic
        # would try to save None → nothing and skip saving the entry we just
        # added.  We set it explicitly here so the state is always consistent.
        _prev_sel[0] = key
        _update_remove_btn()
        _enter_new_entry_mode()

    btn_add.Bind(wx.EVT_BUTTON, on_add_batch)

    # ---- Clear Jobs ----
    def on_reset(evt):
        accounts.clear()
        Current.clear()
        g_dropdown.Clear()
        _prev_sel[0] = None
        _enter_new_entry_mode()

    btn_reset.Bind(wx.EVT_BUTTON, on_reset)

    # ---- Clear Tokens ----
    btn_tokens.Bind(wx.EVT_BUTTON,
                    lambda evt: _show_file_cleaner(parent, 'Clear Tokens', TOKENS_DIR))

    # ---- Clear Logs ----
    btn_logs.Bind(wx.EVT_BUTTON,
                  lambda evt: _show_file_cleaner(parent, 'Clear Logs', LOG_DIR))

    # ---- About ----
    btn_about.Bind(wx.EVT_BUTTON, lambda evt: _show_about(parent))

    # ---- Save List ----
    def on_save(evt):
        dlg = wx.FileDialog(
            parent, 'Save account list',
            defaultFile='accounts.json',
            wildcard='JSON files (*.json)|*.json|All files (*.*)|*.*',
            style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT,
        )
        if dlg.ShowModal() == wx.ID_OK:
            path = dlg.GetPath()
            if not path.lower().endswith('.json'):
                path += '.json'
            try:
                with open(path, 'w', encoding='utf-8') as fh:
                    json.dump(accounts, fh, indent=2)
            except Exception as exc:
                wx.MessageBox(f'Save failed:\n{exc}', 'Error',
                              wx.OK | wx.ICON_ERROR, parent)
        dlg.Destroy()

    btn_save.Bind(wx.EVT_BUTTON, on_save)

    # ---- Load List ----
    def on_load(evt):
        dlg = wx.FileDialog(
            parent, 'Load account list',
            wildcard='JSON files (*.json)|*.json|All files (*.*)|*.*',
            style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
        )
        if dlg.ShowModal() == wx.ID_OK:
            path = dlg.GetPath()
            try:
                with open(path, 'r', encoding='utf-8') as fh:
                    data = json.load(fh)
                accounts.clear()
                accounts.update(data)
                # Reset _prev_sel before _refresh_dropdown so the sentinel is
                # not accidentally re-inserted by the preserve logic.
                _prev_sel[0] = None
                _refresh_dropdown()
                if accounts:
                    first_key = sorted(accounts.keys())[0]
                    _load_entry_to_fields_and_current(accounts[first_key])
                    g_dropdown.SetSelection(g_dropdown.FindString(first_key))
                    _prev_sel[0] = first_key
                    btn_sync.SetLabel(f'Sync {first_key}')
                    _relayout(btn_sync)
                    _update_remove_btn()
                else:
                    _enter_new_entry_mode()
            except Exception as exc:
                wx.MessageBox(f'Load failed:\n{exc}', 'Error',
                              wx.OK | wx.ICON_ERROR, parent)
        dlg.Destroy()

    btn_load.Bind(wx.EVT_BUTTON, on_load)

    return sbox


class TextGauge(wx.Panel):
    """
    A custom progress bar that draws its fill and a centred text label in a
    single widget.  Replaces the wx.Gauge + wx.StaticText pair so the label
    is always visible regardless of window width.
    """
    _HEIGHT = 22

    def __init__(self, parent):
        super().__init__(parent, wx.ID_ANY,
                         size=wx.Size(-1, self._HEIGHT),
                         style=wx.BORDER_SIMPLE)
        self._value = 0          # 0-100
        self._label = ''
        self.SetMinSize(wx.Size(100, self._HEIGHT))
        self.Bind(wx.EVT_PAINT, self._on_paint)
        self.Bind(wx.EVT_SIZE,  lambda e: self.Refresh())

    def SetValue(self, value):
        self._value = max(0, min(100, value))
        self.Refresh()

    def SetLabel(self, text):
        self._label = text
        self.Refresh()

    def _on_paint(self, evt):
        dc   = wx.BufferedPaintDC(self)
        rect = self.GetClientRect()
        w, h = rect.width, rect.height

        # Background
        dc.SetBrush(wx.Brush(self.GetBackgroundColour()))
        dc.SetPen(wx.TRANSPARENT_PEN)
        dc.DrawRectangle(0, 0, w, h)

        # Filled portion
        fill_w = int(w * self._value / 100)
        if fill_w > 0:
            dc.SetBrush(wx.Brush(wx.SystemSettings.GetColour(wx.SYS_COLOUR_HIGHLIGHT)))
            dc.DrawRectangle(0, 0, fill_w, h)

        # Centred label — draw twice: once in highlight-contrast colour over
        # the filled region (clipped), once in normal text colour over the
        # unfilled region (clipped), so the text is always legible.
        if self._label:
            dc.SetFont(self.GetFont())
            tw, th = dc.GetTextExtent(self._label)
            tx = (w - tw) // 2
            ty = (h - th) // 2

            hi_fg  = wx.SystemSettings.GetColour(wx.SYS_COLOUR_HIGHLIGHTTEXT)
            nor_fg = wx.SystemSettings.GetColour(wx.SYS_COLOUR_WINDOWTEXT)

            # Clip to filled area, draw in highlight-text colour
            if fill_w > 0:
                dc.SetClippingRegion(0, 0, fill_w, h)
                dc.SetTextForeground(hi_fg)
                dc.DrawText(self._label, tx, ty)
                dc.DestroyClippingRegion()

            # Clip to unfilled area, draw in normal text colour
            if fill_w < w:
                dc.SetClippingRegion(fill_w, 0, w - fill_w, h)
                dc.SetTextForeground(nor_fg)
                dc.DrawText(self._label, tx, ty)
                dc.DestroyClippingRegion()


def _build_run(parent):
    global g_output, g_btn_cancel, g_progress

    box  = wx.StaticBox(parent, wx.ID_ANY, 'Output')
    sbox = wx.StaticBoxSizer(box, wx.VERTICAL)

    header_row = wx.BoxSizer(wx.HORIZONTAL)
    g_btn_cancel = wx.Button(box, wx.ID_ANY, 'Cancel')
    g_btn_cancel.Enable(False)
    g_btn_cancel.Bind(wx.EVT_BUTTON, lambda evt: cancel_cmd())
    header_row.AddStretchSpacer(1)
    header_row.Add(g_btn_cancel, 0, wx.RIGHT, 4)
    sbox.Add(header_row, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 4)

    g_output = wx.TextCtrl(
        box, wx.ID_ANY, '',
        wx.DefaultPosition, wx.DefaultSize,
        wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_DONTWRAP | wx.HSCROLL,
    )
    g_output.SetFont(
        wx.Font(10, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL,
                wx.FONTWEIGHT_NORMAL)
    )
    sbox.Add(g_output, 1, wx.EXPAND | wx.ALL, 8)

    # Custom progress bar with text label drawn inside the fill.
    # Parented to `box` (the wx.StaticBox) so GTK keeps it in the sizer's
    # coordinate space and Hide()/Show() + Layout() work correctly.
    g_progress = TextGauge(box)
    g_progress.Hide()
    sbox.Add(g_progress, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8)

    return sbox


# ---------------------------------------------------------------------------
# Async command runner
# ---------------------------------------------------------------------------


def say(text):
    """Append text to the output area, adding a newline if needed."""
    if g_output is None:
        return
    if not text.endswith('\n'):
        text += '\n'
    g_output.AppendText(text)


def run_cmd(cmd, on_finish=None):
    global g_timer, g_process, g_on_finish, g_read_thread, g_queue, g_pidfile, g_sync_user

    if g_output is None:
        return

    if g_process is not None and g_process.poll() is None:
        g_output.AppendText(
            '\n[A command is already running -- please wait or Cancel]\n'
        )
        return

    # Use shell=False on all platforms so g_process.pid IS the target
    # binary, not a shell wrapper.
    # - Linux/macOS: shlex.split(posix=True)  + start_new_session=True
    # - Windows:     shlex.split(posix=False) + CREATE_NEW_PROCESS_GROUP
    #   posix=False preserves Windows-style quoted arguments correctly,
    #   and the pre-compiled imapsync.exe is launched directly.

    # Build the argument list.  _build_sync_cmd returns a list so passwords
    # are never touched by shlex; oauth helpers still pass plain strings.
    if isinstance(cmd, list):
        popen_args   = cmd
        # Build a display string masking password values
        display_args = []
        mask_next    = False
        for tok in cmd:
            if mask_next:
                display_args.append('***')
                mask_next = False
            elif tok in ('--password1', '--password2'):
                display_args.append(tok)
                mask_next = True
            else:
                display_args.append(tok)
        display_cmd = ' '.join(display_args)
    else:
        if sys.platform.startswith('win'):
            popen_args = shlex.split(cmd, posix=False)
        else:
            popen_args = shlex.split(cmd, posix=True)
        display_cmd = cmd

    g_output.SetValue(f'Running: {display_cmd}\n' + '-' * 60 + '\n')
    g_on_finish = on_finish

    # Create a unique pidfile for imapsync commands so a future multi-job
    # revision can locate and signal individual processes.  Only imapsync
    # itself honours --pidfile; oauth2_imap calls pass a plain list too but
    # their binary name will not end with 'imapsync'.
    if (isinstance(popen_args, list) and popen_args and
            os.path.basename(popen_args[0]).startswith('imapsync')):
        pf = tempfile.NamedTemporaryFile(
            prefix='mailferry_', suffix='.pid',
            dir=tempfile.gettempdir(), delete=False)
        pf.close()
        g_pidfile = pf.name
        popen_args = popen_args + ['--pidfile', g_pidfile]
    else:
        g_pidfile = None

    # Record which account is syncing so _finish_cmd can write LastSync back.
    if g_dropdown is not None and g_dropdown.GetCount():
        g_sync_user = g_dropdown.GetString(g_dropdown.GetSelection())
    else:
        g_sync_user = None

    # Show progress bar with initial holding message.
    if g_progress is not None:
        g_progress.SetValue(0)
        g_progress.SetLabel('Examining Folders. Please Wait.')
        _progress_set_visible(True)
    try:
        if sys.platform.startswith('win'):
            popen_extra = {
                'creationflags': subprocess.CREATE_NEW_PROCESS_GROUP,
            }
        else:
            popen_extra = {'start_new_session': True}

        g_process = subprocess.Popen(
            popen_args,
            shell=False,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            bufsize=1,
            universal_newlines=True,
            **popen_extra,
        )
    except Exception as exc:
        g_output.AppendText(f'Error: could not run command: {exc}\n')
        return

    if g_btn_cancel is not None:
        g_btn_cancel.Enable(True)

    g_queue = _queue_mod.Queue()

    def _reader():
        try:
            for line in g_process.stdout:
                g_queue.put(line)
        except Exception:
            pass
        finally:
            g_process.stdout.close()
            g_queue.put(None)

    g_read_thread = threading.Thread(target=_reader, daemon=True)
    g_read_thread.start()

    app = wx.App.Get()
    g_timer = wx.Timer(app)

    def _on_timer(evt):
        while True:
            try:
                line = g_queue.get_nowait()
            except _queue_mod.Empty:
                break
            if line is None:
                g_process.wait()
                _finish_cmd(g_process.returncode)
                return
            g_output.AppendText(line)

            # Update progress bar if this is a progress line.
            m = _ETA_RE.search(line)
            if m and g_progress is not None:
                eta_str        = m.group(1).strip()
                # group 2: "2 s" or "12.75 s" -- displayed as-is
                secs_str       = m.group(2).strip()
                # group 3: "5/12" -- split for progress calculation
                left_str, total_str = m.group(3).split('/')
                msgs_left      = int(left_str)
                msgs_total     = int(total_str)
                msgs_done      = msgs_total - msgs_left
                pct            = int(msgs_done * 100 / msgs_total) if msgs_total else 0
                label_text     = f'ETA: {eta_str}  \u2013  {msgs_left}/{msgs_total} msgs left'
                g_progress.SetValue(pct)
                g_progress.SetLabel(label_text)

    app.Bind(wx.EVT_TIMER, _on_timer, g_timer)
    g_timer.Start(100)


def _cleanup_pidfile():
    """Remove the pidfile if it exists."""
    global g_pidfile
    if g_pidfile and os.path.exists(g_pidfile):
        try:
            os.unlink(g_pidfile)
        except OSError:
            pass
    g_pidfile = None


def _kill_process_group():
    """
    Kill the running process and its entire process group.

    Because Popen is launched with shell=False + start_new_session=True,
    g_process.pid IS the target process (python3/imapsync) and is also
    the process group leader.  killpg() therefore reaches every child it
    may have spawned.

    Strategy (Linux/macOS):
      1. SIGTERM to the process group.
      2. Wait up to 2 s for the process to exit.
      3. SIGKILL to the process group if still alive.

    Windows: TerminateProcess on the single PID (no shell wrapper).
    Returns the pid killed, or None.
    """
    if g_process is None:
        return None

    pid = g_process.pid

    if sys.platform.startswith('win'):
        # taskkill /F /T kills the process AND all its children (the tree),
        # which is the Windows equivalent of killpg().
        try:
            subprocess.run(
                ['taskkill', '/F', '/T', '/PID', str(pid)],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
            )
        except Exception:
            pass
        return pid

    try:
        pgid = os.getpgid(pid)
    except OSError:
        pgid = pid  # fallback: treat pid as its own group

    # SIGTERM to the whole process group
    try:
        os.killpg(pgid, signal.SIGTERM)
    except OSError:
        pass

    # Wait up to 2 s
    deadline = time.monotonic() + 2.0
    while time.monotonic() < deadline:
        try:
            os.kill(pid, 0)   # probe: OSError if dead
        except OSError:
            return pid        # gone cleanly
        time.sleep(0.1)

    # Escalate to SIGKILL
    try:
        os.killpg(pgid, signal.SIGKILL)
    except OSError:
        pass

    return pid


def _finish_cmd(rc):
    global g_timer, g_process, g_on_finish

    if g_timer is not None and g_timer.IsRunning():
        g_timer.Stop()
    if g_output is not None:
        g_output.AppendText('-' * 60 + f'\nExit code: {rc}\n')
    if g_btn_cancel is not None:
        g_btn_cancel.Enable(False)

    if g_progress is not None:
        now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        if rc == 130:
            last_sync_text = f'Cancelled at {now_str}'
        else:
            last_sync_text = f'Finished sync at {now_str}'
        g_progress.SetValue(0)
        g_progress.SetLabel(last_sync_text)
        # Persist the final message in the account entry so on_choice can restore it.
        if g_sync_user and g_sync_user in accounts:
            accounts[g_sync_user]['LastSync'] = last_sync_text

    g_process = None
    _cleanup_pidfile()
    if g_on_finish is not None:
        cb, g_on_finish = g_on_finish, None
        cb(rc)


def cancel_cmd():
    global g_process
    if g_process is None or g_process.poll() is not None:
        return
    killed_pid = _kill_process_group()
    if g_output is not None:
        detail = f' (killed PID {killed_pid})' if killed_pid else ''
        g_output.AppendText(f'\n[Cancelled{detail}]\n')
    _finish_cmd(130)


def cmd_running():
    return g_process is not None and g_process.poll() is None


def wait_for_cmd(timeout_ms=0):
    app      = wx.App.Get()
    deadline = (time.monotonic() * 1000 + timeout_ms) if timeout_ms else None
    while cmd_running():
        app.Yield()
        wx.MilliSleep(50)
        if deadline and time.monotonic() * 1000 >= deadline:
            return False
    return True


def _reauth_and_retry(entry, sync_cmd, max_retries=None):
    # Return an on_finish callback for a run_cmd(sync_cmd) call.
    # max_retries: None = read from entry['retries'] (default 3); 1 = single
    # attempt only (used by Test Run); 0 = unlimited.
    # The attempt counter is held in a mutable list so nested closures can
    # increment it without needing 'nonlocal' (Python 2 compat not needed
    # but it keeps the pattern simple).
    if max_retries is None:
        raw = entry.get('retries', 3)
        try:
            max_retries = int(raw)
        except (TypeError, ValueError):
            max_retries = 3

    attempts = [0]   # attempts[0] = number of reauth attempts made so far

    def on_finish(rc):
        if rc not in (161, 162):
            return

        oauth  = entry.get('oauth', '')
        oauth2 = entry.get('oauth2', '')
        user   = entry.get('user', '')
        user2  = entry.get('user2', '')

        tok1 = _token_file(oauth,  user)
        tok2 = _token_file(oauth2, user2)
        oauth2_bin = os.path.join(DATA_DIR, 'oauth2_imap')

        refresh_cmds = []
        # Exit 161 = source token expired; exit 162 = destination token expired.
        # Only refresh the account that imapsync reported as expired.
        if rc == 161:
            if oauth == '365':
                refresh_cmds.append([oauth2_bin, '--provider', 'office365', user,  '--token_file', tok1])
            elif oauth == 'gmail':
                refresh_cmds.append([oauth2_bin, '--provider', 'gmail',     user,  '--token_file', tok1])
        else:  # rc == 162
            if oauth2 == '365':
                refresh_cmds.append([oauth2_bin, '--provider', 'office365', user2, '--token_file', tok2])
            elif oauth2 == 'gmail':
                refresh_cmds.append([oauth2_bin, '--provider', 'gmail',     user2, '--token_file', tok2])

        say('Authentication Failed: Refreshing Tokens')

        if not refresh_cmds:
            say(f'[Exit {rc} received but no OAuth provider configured for the '
                f'{"source" if rc == 161 else "destination"} account -- not retrying]')
            return

        # Check retry limit
        attempts[0] += 1
        if max_retries != 0 and attempts[0] > max_retries:
            say(f'[Retry limit of {max_retries} reached -- giving up]')
            return

        side      = 'source' if rc == 161 else 'destination'
        limit_str = 'unlimited' if max_retries == 0 else str(max_retries)
        say(f'[Token expired on {side} account (exit {rc}) -- refreshing OAuth token, '
            f'attempt {attempts[0]}/{limit_str}...]')

        def make_retry_cb():
            def cb(rc2):
                if rc2 == 0:
                    say('[Token refresh succeeded -- retrying sync...]')
                    # Pass the same callback so the retry limit is respected
                    run_cmd(sync_cmd, on_finish)
                else:
                    say(f'[Token refresh failed (exit {rc2}) -- sync not retried]')
            return cb

        def make_chain(cmds, final_cb):
            if len(cmds) == 1:
                run_cmd(cmds[0], final_cb)
            else:
                def next_cb(rc2):
                    if rc2 == 0:
                        make_chain(cmds[1:], final_cb)
                    else:
                        say(f'[Token refresh failed (exit {rc2}) -- aborting chain]')
                run_cmd(cmds[0], next_cb)

        make_chain(refresh_cmds, make_retry_cb())

    return on_finish


def dosync(account):
    entry = accounts.get(account, {})
    if (g_dropdown is not None and g_dropdown.GetCount() and
            g_dropdown.GetString(g_dropdown.GetSelection()) == account):
        _sync_fields_to_current()
        accounts[account] = dict(Current)
        entry = accounts[account]
    sync_cmd = _build_sync_cmd(entry)
    run_cmd(sync_cmd, _reauth_and_retry(entry, sync_cmd))
    wait_for_cmd()


def sync_all():
    if not accounts:
        if g_output:
            g_output.SetValue('No accounts loaded.\n')
        return
    for acct in sorted(accounts.keys()):
        dosync(acct)


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

if __name__ == '__main__':
    app = App(False)
    app.MainLoop()
