"""
Elite BGS Portal — EDMC Plugin  (with integrated ETNF forwarder)
=================================================================
Captures BGS-relevant journal events and forwards them to the Elite BGS Portal
server using the BGS Tally API v1.8.0 protocol.

Also includes the Elite Tactical Network Forwarder (ETNF) v1.2.0, which acts
as a local HTTP proxy for BGS Tally, multiplexing activity and event data to
any number of upstream servers simultaneously.

Installation (auto):
  The companion app writes this plugin to:
    Windows: %LOCALAPPDATA%/EDMarketConnector/plugins/EliteBGSPortal/load.py
    Linux:   ~/.local/share/EDMarketConnector/plugins/EliteBGSPortal/load.py
             (fallback: ~/.config/EDMarketConnector/plugins/EliteBGSPortal/load.py)
  and creates the config file (elite-bgs-config.json) in the same folder.

Installation (manual):
  Copy this file to the EDMC plugins folder and create elite-bgs-config.json:
    { "slug": "your-squadron-slug", "apiKey": "your-bgs-tally-key",
      "serverUrl": "https://elite-bgs.store" }

Requirements:
  - Python 3.9+  (bundled with EDMC)
  - requests     (bundled with EDMC)
  - tkinter      (bundled with EDMC)
"""

import json
import os
import platform
import queue
import secrets
import threading
import time
import tkinter as tk
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Optional, Callable
from urllib.parse import urlparse
from tkinter import messagebox, ttk

import requests  # bundled with EDMC

# myNotebook is EDMC's themed widget module — must use nb.Frame(parent) for
# plugin_prefs to get a tab in EDMC's Settings dialog.
try:
    import myNotebook as nb  # available when running inside EDMC
except ImportError:
    import tkinter as nb     # fallback for running/testing outside EDMC

# Optional: pynput for truly global hotkeys (works even when EDMC is not focused)
try:
    from pynput import keyboard as _pynput_keyboard
    _PYNPUT_AVAILABLE = True
except ImportError:
    _pynput_keyboard = None
    _PYNPUT_AVAILABLE = False

# ── Constants (Elite BGS Portal) ──────────────────────────────────────────────

PLUGIN_NAME     = 'EliteBGSPortal'
CONFIG_FILE     = 'elite-bgs-config.json'
TICK_API_URL    = 'https://tick.edcd.io/api/tick'
API_VERSION     = '1.8.0'

SEND_INTERVAL_S = 5     # flush event queue every 5 seconds
TICK_INTERVAL_S = 300   # refresh BGS tick every 5 minutes
MAX_BATCH       = 10    # BGS Tally API maximum batch size
MAX_QUEUE       = 500   # drop oldest events if queue exceeds this
PP_SEND_INTERVAL_S = 60  # send PP activity snapshot every 60 seconds

# Events we forward to the server (BGS-relevant subset)
WATCHED_EVENTS = frozenset({
    'FSDJump', 'Location', 'CarrierJump',
    'Docked',
    'MissionAccepted', 'MissionCompleted', 'MissionFailed', 'MissionAbandoned',
    'RedeemVoucher', 'FactionKillBond',
    'MarketBuy', 'MarketSell',
    'MultiSellExplorationData', 'SellExplorationData',
    'CommitCrime', 'Died',
    'ApproachSettlement',
    'StartUp',
})

# Space CZ intensity prefixes from SupercruiseDestinationDrop's Type field
# (case-insensitive prefix match — same convention BGS-Tally uses). Neither
# SupercruiseDestinationDrop nor SupercruiseEntry are in WATCHED_EVENTS — we only
# need them locally to derive CZ wins independently, not to forward raw.
SPACECZ_TYPE_PREFIXES = (
    ('$warzone_pointrace_low',  'l'),
    ('$warzone_pointrace_med',  'm'),
    ('$warzone_pointrace_high', 'h'),
)
SPACECZ_TIMEOUT_S = 300  # 5 minutes, matches BGS-Tally's own window

# Powerplay journal events we track locally (not forwarded via BGS Tally events)
PP_WATCHED_EVENTS = frozenset({
    'PowerplayMeritsGained',
    'PowerplayCollect',
    'PowerplayDeliver',
    'PowerplayFastTrack',
})

# ── Constants (ETNF) ──────────────────────────────────────────────────────────

ETNF_CONFIG_FILE    = 'etnf-config.json'
ETNF_DEFAULT_PORT   = 5001
ETNF_PLUGIN_VERSION = '1.2.0'
ETNF_FORWARD_TIMEOUT = 10   # seconds per upstream call

ADVERTISED_EVENTS = {
    'ApproachSettlement': {}, 'CarrierJump': {},     'CommitCrime': {},  'Died': {},
    'Docked': {},             'FactionKillBond': {}, 'FSDJump': {},      'Location': {},
    'MarketBuy': {},          'MarketSell': {},       'MissionAbandoned': {},
    'MissionAccepted': {},    'MissionCompleted': {}, 'MissionFailed': {},
    'MultiSellExplorationData': {}, 'RedeemVoucher': {}, 'SellExplorationData': {},
    'StartUp': {},            'SyntheticCZ': {},      'SyntheticGroundCZ': {},
    'SyntheticCZObjective': {}, 'SyntheticScenario': {},
    'Powerplay': {},          'PowerplayRank': {},
}

# Human-readable labels for each target type
TARGET_TYPE_LABELS = {
    'bgstally':   'BGS Portal',
    'custom_api': 'Custom API',
    'webhook':    'Webhook',
    'discord':    'Discord',
}

TARGET_TYPE_DESCS = {
    'bgstally': (
        'BGS Portal — speaks the BGS Tally API v1.8.0 protocol.\n'
        'Requires a server URL, squadron slug, and API key from the portal admin page.'
    ),
    'custom_api': (
        'Custom API Server — for BGS-compatible servers with their own URL structure or auth scheme.\n'
        'Examples: Comstar, third-party portals, squadron tools.\n'
        'Configure a URL template (use {endpoint} for the path segment), custom auth header\n'
        'name and value, and HTTP methods per endpoint.'
    ),
    'webhook': (
        'Generic Webhook — posts the raw JSON payload to any HTTP endpoint.\n'
        'Configure the URL, HTTP method, optional custom headers, and which data to forward.'
    ),
    'discord': (
        'Discord Webhook — sends formatted embeds to a Discord channel.\n'
        'Activity snapshots arrive every ~60 s; journal events every 5 s (can be noisy).'
    ),
}

# ── Module-level state (Elite BGS Portal) ────────────────────────────────────

_plugin_dir   = None
_config       = {}          # {slug, apiKey, serverUrl}
_events_q     = queue.Queue(maxsize=MAX_QUEUE)
_stop_flag    = threading.Event()
_lock         = threading.Lock()

_current_sys   = None       # last known StarSystem
_current_addr  = None       # last known SystemAddress
_tick_id       = None       # current BGS tick ID from tick.edcd.io
_tick_time     = None       # current BGS tick time (ISO 8601)

# Independent Space CZ win detection — mirrors BGS-Tally's own algorithm so we
# still credit a CZ win even when BGS-Tally's local detector fails to (see the
# Operations-era regression: kills fire fine, BGS-Tally's own counter doesn't).
# {'timestamp': iso8601 str, 'intensity': 'l'/'m'/'h', 'counted': bool}
_last_cz_approached = {}

_status_var    = None       # tk.StringVar for the prefs UI status line
_last_err      = None       # last send error for UI display
_hotkey_listener = None     # pynput GlobalHotKeys instance (Linux global hotkey)

_linux_panel   = None       # LinuxOverlayPanel instance (Linux only)

# ── Powerplay activity accumulator (session totals, reset on tick rollover) ────
_pp_lock            = threading.Lock()
_pp_cmdr            = None      # CMDR name captured from journal_entry
_pp_power           = None      # current pledged power
_pp_current_system  = None      # system where PP activity last occurred
_pp_merits_gained   = 0         # total merits earned this session
_pp_activity        = {         # breakdown by reason
    'CombatBond': 0,
    'Deliver':    0,
    'Collect':    0,
    'Mission':    0,
    'FastTrack':  0,
    'Other':      0,
}

# ── Linux overlay panel ───────────────────────────────────────────────────────

_IS_LINUX = platform.system() == 'Linux'

# Faction data cached from the last FSDJump / Location event
_current_factions: list = []   # [{'name': str, 'influence': float, 'state': str}]

# ── Module-level state (ETNF) ─────────────────────────────────────────────────

_etnf_config: dict                          = {}
_etnf_server: Optional[HTTPServer]          = None
_etnf_server_thread: Optional[threading.Thread] = None
_etnf_status_var: Optional[tk.StringVar]   = None
_etnf_count_var:  Optional[tk.StringVar]   = None


# ── LinuxOverlayPanel ─────────────────────────────────────────────────────────

class LinuxOverlayPanel:
    """
    Interactive Tkinter overlay panel for Linux — EDMC plugin edition.

    Three navigable tabs:
      BGS   — faction influence for the current system (journal data, live)
      TALLY — squadron BGS activity this tick (portal API, refreshed every 60 s)
      PP    — Powerplay merit tracking this tick (portal API)

    Toggle visibility: Ctrl+Shift+B (bound to EDMC root window).
    Drag by the header bar to reposition.
    """

    BG       = '#020408'
    BG_HDR   = '#040c12'
    FG_ACC   = '#00d4ff'
    FG_TEXT  = '#d8eaf6'
    FG_DIM   = '#6a8fa8'
    FG_GRN   = '#00ff88'
    FG_RED   = '#ff2040'
    FG_YLW   = '#ffd700'
    TAB_ACT  = '#00d4ff'
    TAB_IDLE = '#1a3040'
    W        = 310
    H        = 440

    TABS = ('BGS', 'TALLY', 'PP')

    def __init__(self, tk_root: tk.Misc) -> None:
        self._root      = tk_root
        self._win: tk.Toplevel | None = None
        self._visible   = False
        self._after_id  = None
        self._tab       = 'BGS'          # active tab
        self._portal_data: dict = {}     # cached overlay-data response
        self._drag_x    = 0
        self._drag_y    = 0

        # Widget refs (created in _create_window)
        self._tab_btns: dict  = {}
        self._content: tk.Label | None = None
        self._lbl_status: tk.Label | None = None

    # ── Public API ─────────────────────────────────────────────────────────────

    def toggle(self) -> None:
        if self._visible:
            self.hide()
        else:
            self.show()

    def show(self) -> None:
        if self._win is None:
            self._create_window()
        self._win.deiconify()
        self._win.attributes('-topmost', True)
        self._visible = True
        self._schedule_refresh()

    def hide(self) -> None:
        if self._win:
            self._win.withdraw()
        self._visible = False
        if self._after_id:
            try:
                self._root.after_cancel(self._after_id)
            except Exception:
                pass
            self._after_id = None

    def destroy(self) -> None:
        self.hide()
        if self._win:
            try:
                self._win.destroy()
            except Exception:
                pass
            self._win = None

    def refresh(self) -> None:
        """Force a UI refresh from the main thread (safe to call any time)."""
        if self._visible:
            self._render_tab()

    def update_portal_data(self, data: dict) -> None:
        """Called from the background fetch thread via root.after()."""
        self._portal_data = data
        if self._visible and self._tab in ('TALLY', 'PP'):
            self._render_tab()

    # ── Window construction ────────────────────────────────────────────────────

    def _create_window(self) -> None:
        # winfo_screenwidth() can return 0 on Wayland before the window is mapped;
        # force an update first, then fall back to 1920 if still zero.
        try:
            self._root.update_idletasks()
        except Exception:
            pass
        sw = self._root.winfo_screenwidth()
        if sw <= 0:
            sw = 1920
        x_pos = sw - self.W - 16
        if x_pos < 0:
            x_pos = 20   # never place off the left edge

        self._win = tk.Toplevel(self._root)
        self._win.title('Elite BGS Portal')
        self._win.overrideredirect(True)
        self._win.attributes('-topmost', True)
        self._win.configure(bg=self.BG)
        self._win.resizable(False, False)
        self._win.geometry(f'{self.W}x{self.H}+{x_pos}+80')
        self._win.update_idletasks()   # ensure geometry is applied before show

        self._build_header()
        self._build_tabs()
        tk.Frame(self._win, bg='#0a2030', height=1).pack(fill='x', padx=0)
        self._build_content()
        self._build_statusbar()

        # Bind Ctrl+Shift+B on the overlay window itself too
        self._win.bind('<Control-Shift-B>', lambda e: self.toggle())
        self._win.bind('<Control-Shift-b>', lambda e: self.toggle())

    def _build_header(self) -> None:
        hdr = tk.Frame(self._win, bg=self.BG_HDR, height=28)
        hdr.pack(fill='x')
        hdr.pack_propagate(False)

        tk.Label(
            hdr, text='◈ ELITE BGS PORTAL', fg=self.FG_ACC, bg=self.BG_HDR,
            font=('Courier', 9, 'bold'),
        ).pack(side='left', padx=8, pady=4)

        tk.Button(
            hdr, text='✕', fg=self.FG_DIM, bg=self.BG_HDR, bd=0,
            activeforeground=self.FG_RED, activebackground=self.BG_HDR,
            command=self.hide, font=('Courier', 10), cursor='hand2', padx=6,
        ).pack(side='right')

        hdr.bind('<ButtonPress-1>', self._on_drag_start)
        hdr.bind('<B1-Motion>',     self._on_drag_motion)
        for child in hdr.winfo_children():
            if isinstance(child, tk.Label):
                child.bind('<ButtonPress-1>', self._on_drag_start)
                child.bind('<B1-Motion>',     self._on_drag_motion)

    def _build_tabs(self) -> None:
        tab_bar = tk.Frame(self._win, bg=self.BG, pady=0)
        tab_bar.pack(fill='x', padx=6, pady=(4, 0))
        for name in self.TABS:
            btn = tk.Button(
                tab_bar, text=name,
                font=('Courier', 8, 'bold'),
                bg=self.TAB_ACT if name == self._tab else self.TAB_IDLE,
                fg=self.BG if name == self._tab else self.FG_DIM,
                bd=0, padx=10, pady=3, cursor='hand2',
                activebackground=self.FG_ACC, activeforeground=self.BG,
                command=lambda n=name: self._switch_tab(n),
            )
            btn.pack(side='left', padx=(0, 2))
            self._tab_btns[name] = btn

    def _build_content(self) -> None:
        frame = tk.Frame(self._win, bg=self.BG)
        frame.pack(fill='both', expand=True, padx=0, pady=0)
        self._content = tk.Label(
            frame, text='', fg=self.FG_TEXT, bg=self.BG,
            font=('Courier', 8),
            wraplength=self.W - 16, justify='left', anchor='nw',
        )
        self._content.pack(fill='both', expand=True, padx=8, pady=6)

    def _build_statusbar(self) -> None:
        bar = tk.Frame(self._win, bg='#040c12', height=18)
        bar.pack(fill='x', side='bottom')
        bar.pack_propagate(False)
        self._lbl_status = tk.Label(
            bar, text='◈ Ctrl+Shift+B to dismiss',
            fg='#2a4a5e', bg='#040c12', font=('Courier', 7), anchor='w',
        )
        self._lbl_status.pack(side='left', padx=6)

    # ── Tab switching ──────────────────────────────────────────────────────────

    def _switch_tab(self, name: str) -> None:
        self._tab = name
        for n, btn in self._tab_btns.items():
            btn.config(
                bg=self.TAB_ACT  if n == name else self.TAB_IDLE,
                fg=self.BG       if n == name else self.FG_DIM,
            )
        self._render_tab()

    # ── Rendering ──────────────────────────────────────────────────────────────

    def _render_tab(self) -> None:
        if not self._content:
            return
        if self._tab == 'BGS':
            self._render_bgs()
        elif self._tab == 'TALLY':
            self._render_tally()
        elif self._tab == 'PP':
            self._render_pp()

    def _render_bgs(self) -> None:
        sys_name  = _current_sys or '—'
        factions  = _current_factions
        ts        = datetime.now(timezone.utc).strftime('%H:%M UTC')

        lines = [f'► {sys_name}', '']
        if not factions:
            lines.append('Jump to a system to load data.')
        else:
            for f in factions[:14]:
                name  = f.get('name', '')[:28]
                inf   = f.get('influence', 0.0) * 100
                state = f.get('FactionState', '')
                tag   = f' [{state}]' if state and state not in ('None', '') else ''
                bar_len = int(inf / 5)
                bar   = '█' * bar_len + '░' * (20 - bar_len)
                lines.append(f'{inf:5.1f}%  {name}{tag}')

        self._content.config(text='\n'.join(lines))
        self._set_status(f'◈ journal · {ts}  |  Ctrl+Shift+B to dismiss')

    def _render_tally(self) -> None:
        data  = self._portal_data
        tally = data.get('tally', {})
        lines = []

        tick_time = tally.get('tickTime', '')
        if tick_time:
            try:
                ts = tick_time[:16].replace('T', ' ') + ' UTC'
                lines.append(f'Tick: {ts}')
            except Exception:
                pass
        lines.append('')

        cmdrs = tally.get('cmdrs', {})
        if not cmdrs:
            lines.append('No activity data yet.')
            lines.append('')
            lines.append('Waiting for BGS Tally submissions...')
        else:
            for cmdr_name, cmdr in sorted(cmdrs.items()):
                sys_name = cmdr.get('currentSystem') or '?'
                lines.append(f'▸ {cmdr_name}  [{sys_name}]')
                systems = cmdr.get('systems', {})
                if not systems:
                    lines.append('  no activity this tick')
                else:
                    for sname, factions in systems.items():
                        for fname, act in factions.items():
                            parts = []
                            if act.get('bonds'):    parts.append(f"CBs:{act['bonds']}")
                            if act.get('bounties'): parts.append(f"BVs:{act['bounties']}")
                            if act.get('missions'): parts.append(f"Msn:{act['missions']}")
                            cz = act.get('czLow', 0) + act.get('czMed', 0) + act.get('czHigh', 0)
                            if cz: parts.append(f"CZ:{cz}")
                            if parts:
                                short = fname[:22]
                                lines.append(f'  {short}: {" ".join(parts)}')
                lines.append('')

        self._content.config(text='\n'.join(lines).rstrip())
        ts = datetime.now(timezone.utc).strftime('%H:%M UTC')
        self._set_status(f'◈ portal · {ts}  |  Ctrl+Shift+B to dismiss')

    def _render_pp(self) -> None:
        data  = self._portal_data
        pp    = data.get('pp', {})
        lines = []

        cmdrs = pp.get('cmdrs', {})
        if not cmdrs:
            lines.append('No Powerplay activity this tick.')
            lines.append('')
            lines.append('Merits are tracked automatically')
            lines.append('when you earn them in-game.')
        else:
            for cmdr_name, cmdr in sorted(cmdrs.items()):
                power = cmdr.get('power') or 'Unknown Power'
                merits = cmdr.get('meritsGained', 0)
                sys_name = cmdr.get('currentSystem') or '?'
                lines.append(f'▸ {cmdr_name}')
                lines.append(f'  {power}')
                lines.append(f'  Total merits: {merits:,}  [{sys_name}]')
                act = cmdr.get('activity', {})
                breakdown = []
                if act.get('CombatBond'): breakdown.append(f"CZ:{act['CombatBond']:,}")
                if act.get('Deliver'):    breakdown.append(f"Del:{act['Deliver']}")
                if act.get('Collect'):    breakdown.append(f"Col:{act['Collect']}")
                if act.get('Mission'):    breakdown.append(f"Msn:{act['Mission']}")
                if breakdown:
                    lines.append(f'  {" · ".join(breakdown)}')
                lines.append('')

        self._content.config(text='\n'.join(lines).rstrip())
        ts = datetime.now(timezone.utc).strftime('%H:%M UTC')
        self._set_status(f'◈ portal · {ts}  |  Ctrl+Shift+B to dismiss')

    def _set_status(self, text: str) -> None:
        if self._lbl_status:
            self._lbl_status.config(text=text)

    # ── Drag support ───────────────────────────────────────────────────────────

    def _on_drag_start(self, event) -> None:
        self._drag_x = event.x_root - self._win.winfo_x()
        self._drag_y = event.y_root - self._win.winfo_y()

    def _on_drag_motion(self, event) -> None:
        self._win.geometry(f'+{event.x_root - self._drag_x}+{event.y_root - self._drag_y}')

    # ── Scheduled refresh ──────────────────────────────────────────────────────

    def _schedule_refresh(self) -> None:
        if not self._visible:
            return
        self._render_tab()
        self._after_id = self._root.after(5000, self._schedule_refresh)


# ── EDMC lifecycle hooks ───────────────────────────────────────────────────────

def plugin_start3(plugin_dir: str) -> str:
    """Called by EDMC on launch. Must return the plugin name string."""
    global _plugin_dir, _etnf_config, _etnf_server_thread
    _plugin_dir = plugin_dir
    _load_config(plugin_dir)

    threading.Thread(
        target=_sender_loop, daemon=True,
        name='EliteBGSPortal-Sender',
    ).start()
    threading.Thread(
        target=_tick_loop, daemon=True,
        name='EliteBGSPortal-Tick',
    ).start()
    threading.Thread(
        target=_pp_sender_loop, daemon=True,
        name='EliteBGSPortal-PPSender',
    ).start()
    threading.Thread(
        target=_portal_fetch_loop, daemon=True,
        name='EliteBGSPortal-PortalFetch',
    ).start()

    _etnf_config = _etnf_load_config(plugin_dir)
    _etnf_server_thread = threading.Thread(target=_etnf_start_proxy, daemon=True, name='ETNF-Proxy')
    _etnf_server_thread.start()

    configured = bool(_config.get('slug') and _config.get('apiKey'))
    print(f'[EliteBGSPortal] Started. Configured: {configured}')
    port = _etnf_config.get('local_port', ETNF_DEFAULT_PORT)
    n    = len([t for t in _etnf_config.get('targets', []) if t.get('enabled', True)])
    print(f'[EliteBGSPortal] ETNF v{ETNF_PLUGIN_VERSION} — proxy http://127.0.0.1:{port} -> {n} active target(s)')
    return PLUGIN_NAME


def plugin_app(parent) -> tk.Frame | None:
    """
    Called by EDMC to add a frame to the main window (shown while flying).
    On Linux we initialise the overlay panel and register Ctrl+Shift+B.
    """
    global _linux_panel, _hotkey_listener
    if _IS_LINUX and _linux_panel is None:
        root = parent.winfo_toplevel()
        _linux_panel = LinuxOverlayPanel(root)

        # Show the overlay immediately on startup so the user can see it.
        # They can dismiss it with Ctrl+Shift+B or the prefs-panel button.
        try:
            root.after(500, _linux_panel.show)
        except Exception:
            pass

        # Always bind within EDMC window (fallback / redundant coverage)
        root.bind_all('<Control-Shift-B>', lambda e: _linux_panel.toggle())
        root.bind_all('<Control-Shift-b>', lambda e: _linux_panel.toggle())

        # pynput global hotkey — fires even when Elite Dangerous has focus
        if _PYNPUT_AVAILABLE and _hotkey_listener is None:
            def _on_hotkey():
                # Must schedule on Tk main thread
                try:
                    root.after(0, _linux_panel.toggle)
                except Exception:
                    pass

            try:
                _hotkey_listener = _pynput_keyboard.GlobalHotKeys({
                    '<ctrl>+<shift>+b': _on_hotkey,
                })
                _hotkey_listener.start()
                print('[EliteBGSPortal] Global hotkey Ctrl+Shift+B registered via pynput')
            except Exception as e:
                print(f'[EliteBGSPortal] pynput hotkey failed: {e} — falling back to Tkinter bind')
                _hotkey_listener = None
        elif not _PYNPUT_AVAILABLE:
            print('[EliteBGSPortal] pynput not available — Ctrl+Shift+B works only when EDMC is focused')

    return None   # we add no main-window widget


def plugin_stop() -> None:
    """Called when EDMC is closing. Signal background threads to exit."""
    global _etnf_server
    _stop_flag.set()
    if _hotkey_listener:
        try:
            _hotkey_listener.stop()
        except Exception:
            pass
    if _linux_panel:
        _linux_panel.destroy()
    if _etnf_server:
        _etnf_server.shutdown()
        _etnf_server = None


def plugin_prefs(parent, cmdr: str, is_beta: bool):
    """
    Build the preferences panel shown in EDMC settings.
    Must return nb.Frame(parent) — EDMC's notebook only recognises its own
    themed frame type when deciding whether to create a settings tab.
    """
    # nb.Frame is EDMC's myNotebook.Frame; using tk.Frame here causes EDMC
    # to silently skip creating the settings tab for this plugin.
    frame = nb.Frame(parent)
    frame.columnconfigure(0, weight=0)
    frame.columnconfigure(1, weight=1)
    frame.columnconfigure(2, weight=0)
    frame.columnconfigure(3, weight=0)

    try:
        _build_prefs_content(frame, parent)
    except Exception as exc:
        import traceback
        msg = traceback.format_exc()
        print(f'[EliteBGSPortal] plugin_prefs error:\n{msg}')
        tk.Label(frame, text=f'Settings failed to load — check EDMC log.\n{exc}',
                 foreground='red', justify='left', wraplength=440
                 ).grid(row=0, column=0, columnspan=4, padx=10, pady=10, sticky='w')

    return frame


def _build_prefs_content(frame: tk.Frame, dialog_parent) -> None:
    """Populate the prefs frame with all settings content."""
    global _linux_panel, _status_var

    row = 0

    # ══════════════════════════════════════════════════════════════════
    # SECTION A — Elite BGS Portal connection
    # ══════════════════════════════════════════════════════════════════
    tk.Label(frame, text='Elite BGS Portal',
             font=('TkDefaultFont', 11, 'bold')
             ).grid(row=row, column=0, columnspan=3, sticky='w', padx=10, pady=(10, 2))
    row += 1

    _status_var = tk.StringVar(value=_build_status())
    tk.Label(frame, text='Status:').grid(row=row, column=0, sticky='w', padx=10)
    tk.Label(frame, textvariable=_status_var, justify='left',
             wraplength=380).grid(row=row, column=1, columnspan=2, sticky='w', padx=4)
    row += 1

    # Editable portal fields
    srv_var  = tk.StringVar(value=_config.get('serverUrl', 'https://elite-bgs.store'))
    slug_var = tk.StringVar(value=_config.get('slug', ''))
    key_var  = tk.StringVar(value=_config.get('apiKey',  ''))

    def _lbl_entry(r, label, var, width=38, secret=False):
        tk.Label(frame, text=label).grid(row=r, column=0, sticky='w', padx=10, pady=2)
        show = '*' if secret else ''
        ent  = tk.Entry(frame, textvariable=var, width=width, show=show)
        ent.grid(row=r, column=1, columnspan=2, sticky='w', padx=4, pady=2)
        if secret:
            rv = tk.BooleanVar(value=False)
            def _tog(e=ent, r=rv):
                e.config(show='' if r.get() else '*')
            tk.Checkbutton(frame, text='Show', variable=rv, command=_tog
                           ).grid(row=r, column=3, padx=4)

    _lbl_entry(row, 'Server URL:', srv_var)
    row += 1
    _lbl_entry(row, 'Squadron slug:', slug_var)
    row += 1
    _lbl_entry(row, 'API key:', key_var, secret=True)
    row += 1

    def _save_portal():
        srv  = srv_var.get().strip().rstrip('/')
        slug = slug_var.get().strip().strip('/')
        key  = key_var.get().strip()
        if not srv or not slug or not key:
            messagebox.showerror('Elite BGS Portal',
                                 'Server URL, squadron slug, and API key are all required.',
                                 parent=dialog_parent)
            return
        _config['serverUrl'] = srv
        _config['slug']      = slug
        _config['apiKey']    = key
        cfg_path = os.path.join(_plugin_dir, CONFIG_FILE)
        try:
            with open(cfg_path, 'w', encoding='utf-8') as f:
                json.dump(_config, f, indent=2)
            _status_var.set(_build_status())
            messagebox.showinfo('Elite BGS Portal', 'Settings saved.', parent=dialog_parent)
        except Exception as exc:
            messagebox.showerror('Elite BGS Portal', f'Could not save config:\n{exc}',
                                 parent=dialog_parent)

    tk.Button(frame, text='Save Portal Settings', command=_save_portal
              ).grid(row=row, column=1, sticky='w', padx=4, pady=(4, 8))
    row += 1

    # ── Linux overlay ──────────────────────────────────────────────────────────
    if _IS_LINUX:
        if _linux_panel is None:
            try:
                root = dialog_parent.winfo_toplevel()
                _linux_panel = LinuxOverlayPanel(root)
            except Exception:
                pass

        tk.Frame(frame, height=1, bg='gray').grid(
            row=row, column=0, columnspan=4, sticky='ew', padx=10, pady=6)
        row += 1

        tk.Label(frame, text='Linux Overlay',
                 font=('TkDefaultFont', 10, 'bold')
                 ).grid(row=row, column=0, columnspan=4, sticky='w', padx=10, pady=(0, 2))
        row += 1

        hk = 'Global hotkey active (pynput)' if _PYNPUT_AVAILABLE else \
             'Install pynput for global hotkey: pip install pynput'
        tk.Label(frame,
                 text=f'Interactive BGS/TALLY/PP overlay — always on top.\nToggle: Ctrl+Shift+B  ({hk})',
                 foreground='gray', justify='left', wraplength=420
                 ).grid(row=row, column=0, columnspan=4, sticky='w', padx=10, pady=(0, 4))
        row += 1

        def _toggle_overlay():
            if _linux_panel:
                _linux_panel.toggle()

        tk.Button(frame, text='Toggle BGS Overlay Panel', command=_toggle_overlay
                  ).grid(row=row, column=0, columnspan=2, sticky='w', padx=10, pady=(0, 8))
        row += 1

    # ══════════════════════════════════════════════════════════════════
    # SECTION B — ETNF Forwarder
    # ══════════════════════════════════════════════════════════════════
    tk.Frame(frame, height=2, bg='#555555').grid(
        row=row, column=0, columnspan=4, sticky='ew', padx=6, pady=(6, 4))
    row += 1

    _etnf_prefs_section(frame, row, dialog_parent)


def prefs_changed(cmdr: str, is_beta: bool) -> None:
    """Called when the user closes the prefs dialog."""
    if _status_var:
        _status_var.set(_build_status())
    _etnf_refresh_count()


def journal_entry(cmdr: str, is_beta: bool, system: str, station,
                  entry: dict, state: dict) -> None:
    """Called for every journal line. We enrich and queue BGS-relevant events."""
    global _current_sys, _current_addr, _pp_cmdr, _last_cz_approached

    # Always capture the CMDR name for the PP sender
    if cmdr:
        _pp_cmdr = cmdr

    event = entry.get('event')
    if not event:
        return

    # Always track current system for enriching subsequent events
    if event in ('FSDJump', 'Location', 'CarrierJump') and entry.get('StarSystem'):
        with _lock:
            _current_sys  = entry['StarSystem']
            _current_addr = entry.get('SystemAddress')
        # Cache faction list for the Linux overlay panel
        if _IS_LINUX and entry.get('Factions'):
            global _current_factions
            _current_factions = entry['Factions']
            if _linux_panel and _linux_panel._visible:
                # Refresh labels from the main thread
                try:
                    _linux_panel._root.after(0, _linux_panel.refresh)
                except Exception:
                    pass

    # ── Independent Space CZ win detection ────────────────────────────────────
    # Runs regardless of WATCHED_EVENTS so we catch SupercruiseEntry/DestinationDrop
    # locally without forwarding them raw. See SPACECZ_TYPE_PREFIXES above.
    if event == 'SupercruiseEntry':
        with _lock:
            _last_cz_approached = {}
    elif event == 'ApproachSettlement':
        with _lock:
            _last_cz_approached = {}
    elif event == 'SupercruiseDestinationDrop':
        cz_type = (entry.get('Type') or '').lower()
        matched = None
        for prefix, intensity in SPACECZ_TYPE_PREFIXES:
            if cz_type.startswith(prefix):
                matched = intensity
                break
        with _lock:
            if matched:
                _last_cz_approached = {
                    'timestamp': entry.get('timestamp'),
                    'intensity': matched,
                    'counted':   False,
                }
            else:
                _last_cz_approached = {}
    elif event == 'FactionKillBond':
        with _lock:
            approached = dict(_last_cz_approached)
        if approached and not approached.get('counted') and approached.get('timestamp') and entry.get('AwardingFaction'):
            try:
                dropped_at = datetime.strptime(approached['timestamp'], '%Y-%m-%dT%H:%M:%SZ')
                kill_at    = datetime.strptime(entry.get('timestamp', ''), '%Y-%m-%dT%H:%M:%SZ')
                in_window  = (kill_at - dropped_at).total_seconds() <= SPACECZ_TIMEOUT_S
            except (ValueError, TypeError):
                in_window = False
            if in_window:
                with _lock:
                    if _last_cz_approached.get('timestamp') == approached['timestamp']:
                        _last_cz_approached['counted'] = True
                cz_win = {
                    'event':           'ETNSpaceCZWin',
                    'timestamp':       entry.get('timestamp'),
                    'StarSystem':      entry.get('StarSystem') or system or _current_sys,
                    'AwardingFaction': entry.get('AwardingFaction'),
                    'intensity':       approached['intensity'],
                    'cmdr':            cmdr,
                }
                with _lock:
                    cz_win['tickid']   = _tick_id
                    cz_win['ticktime'] = _tick_time
                try:
                    _events_q.put_nowait(cz_win)
                except queue.Full:
                    try:
                        _events_q.get_nowait()
                        _events_q.put_nowait(cz_win)
                    except queue.Empty:
                        pass

    # ── Powerplay event tracking ──────────────────────────────────────────────
    if event in PP_WATCHED_EVENTS:
        _handle_pp_event(event, entry)

    if event not in WATCHED_EVENTS:
        return

    if not _config.get('slug') or not _config.get('apiKey'):
        return  # not yet configured — drop silently

    # Build enriched copy
    enriched = dict(entry)
    enriched['cmdr'] = cmdr

    with _lock:
        if not enriched.get('StarSystem'):
            enriched['StarSystem'] = _current_sys
        if not enriched.get('SystemAddress'):
            enriched['SystemAddress'] = _current_addr
        enriched['tickid']   = _tick_id
        enriched['ticktime'] = _tick_time

    try:
        _events_q.put_nowait(enriched)
    except queue.Full:
        # Queue full — drain oldest event to make room (best-effort)
        try:
            _events_q.get_nowait()
            _events_q.put_nowait(enriched)
        except queue.Empty:
            pass


# ── Internal helpers (Elite BGS Portal) ──────────────────────────────────────

def _load_config(plugin_dir: str) -> None:
    """Load elite-bgs-config.json written by the companion app."""
    global _config
    cfg_path = os.path.join(plugin_dir, CONFIG_FILE)
    try:
        with open(cfg_path, 'r', encoding='utf-8') as f:
            _config = json.load(f)
    except (FileNotFoundError, json.JSONDecodeError, OSError):
        _config = {}


def _build_status() -> str:
    """Return a human-readable status string for the prefs UI."""
    global _last_err
    if not _config.get('slug'):
        return 'Not configured — log in with the Elite BGS Companion app.'
    srv = _config.get('serverUrl', 'https://elite-bgs.store').rstrip('/')
    base = f"Sending to: {srv}/t/{_config['slug']}/bgstally/"
    if _last_err:
        return f"{base}\nLast error: {_last_err}"
    return base


def _sender_loop() -> None:
    """
    Background thread.
    Drains the event queue every SEND_INTERVAL_S seconds and POSTs batches
    of up to MAX_BATCH events to /t/:slug/bgstally/events.
    On 401 the API key is invalidated so we stop hammering the server.
    On network failure the batch is re-queued for the next attempt.
    """
    global _last_err

    while not _stop_flag.is_set():
        time.sleep(SEND_INTERVAL_S)

        if not _config.get('slug') or not _config.get('apiKey'):
            continue

        batch: list = []
        while len(batch) < MAX_BATCH:
            try:
                batch.append(_events_q.get_nowait())
            except queue.Empty:
                break

        if not batch:
            continue

        srv  = _config['serverUrl'].rstrip('/')
        slug = _config['slug']
        url  = f'{srv}/t/{slug}/bgstally/events'

        try:
            resp = requests.post(
                url,
                json=batch,
                headers={
                    'Content-Type': 'application/json',
                    'apikey':       _config['apiKey'],
                    'apiversion':   API_VERSION,
                },
                timeout=10,
            )
            if resp.status_code == 401:
                # Invalid key — stop sending; require re-configuration
                print(f'[EliteBGSPortal] 401 Invalid API key for [{slug}] — disabling sends')
                _config['apiKey'] = None
                _last_err = 'API key rejected (401). Re-login with the companion app.'
            elif resp.status_code >= 400:
                _last_err = f'Server error {resp.status_code}'
            else:
                _last_err = None  # clear on success

        except requests.exceptions.RequestException as exc:
            _last_err = str(exc)
            # Re-queue the batch so it's retried next cycle
            for ev in batch:
                try:
                    _events_q.put_nowait(ev)
                except queue.Full:
                    break  # give up if still full

        # Fan out the same batch to all enabled ETNF targets (best-effort)
        _etnf_fan_out_events(batch)


def _handle_pp_event(event: str, entry: dict) -> None:
    """Accumulate Powerplay journal events into the session totals."""
    global _pp_power, _pp_current_system, _pp_merits_gained, _pp_activity

    with _pp_lock:
        if event == 'PowerplayMeritsGained':
            power   = entry.get('Power', '')
            merits  = int(entry.get('MeritsGained', 0))
            reason  = entry.get('Reason', 'Other')

            if power:
                _pp_power = power
            _pp_merits_gained += merits

            # Map journal Reason values to our activity buckets
            reason_map = {
                'CombatBond': 'CombatBond',
                'Combat':     'CombatBond',
                'Deliver':    'Deliver',
                'Collect':    'Collect',
                'Mission':    'Mission',
                'FastTrack':  'FastTrack',
            }
            bucket = reason_map.get(reason, 'Other')
            _pp_activity[bucket] = _pp_activity.get(bucket, 0) + merits

            # Track which system the PP activity happened in
            with _lock:
                _pp_current_system = _current_sys

        elif event == 'PowerplayDeliver':
            power = entry.get('Power', '')
            if power:
                _pp_power = power
            _pp_activity['Deliver'] = _pp_activity.get('Deliver', 0) + 1
            with _lock:
                _pp_current_system = _current_sys

        elif event == 'PowerplayCollect':
            power = entry.get('Power', '')
            if power:
                _pp_power = power
            _pp_activity['Collect'] = _pp_activity.get('Collect', 0) + 1
            with _lock:
                _pp_current_system = _current_sys

        elif event == 'PowerplayFastTrack':
            _pp_activity['FastTrack'] = _pp_activity.get('FastTrack', 0) + 1


def _pp_sender_loop() -> None:
    """
    Background thread.
    Sends a Powerplay activity snapshot to /t/:slug/powerplay/activities
    every PP_SEND_INTERVAL_S seconds (60 s by default).
    Only sends if there is any PP activity to report this session.
    """
    while not _stop_flag.is_set():
        # Sleep in 1 s increments for a responsive shutdown
        for _ in range(PP_SEND_INTERVAL_S):
            if _stop_flag.is_set():
                return
            time.sleep(1)

        if not _config.get('slug') or not _config.get('apiKey'):
            continue

        with _pp_lock:
            if _pp_merits_gained == 0 and all(v == 0 for v in _pp_activity.values()):
                continue  # nothing to report yet
            power          = _pp_power
            merits_gained  = _pp_merits_gained
            activity       = dict(_pp_activity)
            current_system = _pp_current_system

        with _lock:
            tick_id   = _tick_id
            tick_time = _tick_time

        srv  = _config['serverUrl'].rstrip('/')
        slug = _config['slug']
        url  = f'{srv}/t/{slug}/powerplay/activities'

        payload = {
            'cmdr':          None,   # filled below — need cmdr from EDMC state
            'power':         power,
            'tickid':        tick_id,
            'ticktime':      tick_time,
            'meritsGained':  merits_gained,
            'activity':      activity,
            'currentSystem': current_system,
        }

        # We need the CMDR name — use a best-effort approach via stored state
        # (EDMC passes cmdr to journal_entry; store it module-level)
        cmdr = _pp_cmdr
        if not cmdr:
            continue
        payload['cmdr'] = cmdr

        try:
            requests.put(
                url,
                json=payload,
                headers={
                    'Content-Type': 'application/json',
                    'apikey':       _config['apiKey'],
                },
                timeout=10,
            )
        except Exception:
            pass  # best-effort; will retry next cycle


def _portal_fetch_loop() -> None:
    """
    Background thread — fetches overlay-data from the portal every 60 s
    and pushes it to the LinuxOverlayPanel via root.after() (thread-safe).
    """
    # Stagger startup so the plugin finishes loading before the first fetch
    for _ in range(15):
        if _stop_flag.is_set():
            return
        time.sleep(1)

    while not _stop_flag.is_set():
        if _config.get('slug') and _config.get('apiKey'):
            try:
                srv  = _config['serverUrl'].rstrip('/')
                slug = _config['slug']
                url  = f'{srv}/t/{slug}/bgstally/overlay-data'
                resp = requests.get(
                    url,
                    headers={'apikey': _config['apiKey']},
                    timeout=10,
                )
                if resp.status_code == 200:
                    data = resp.json()
                    # Deliver to the overlay on the main thread
                    if _linux_panel and _linux_panel._root:
                        try:
                            _linux_panel._root.after(
                                0, lambda d=data: _linux_panel.update_portal_data(d)
                            )
                        except Exception:
                            pass
            except Exception:
                pass

        for _ in range(60):
            if _stop_flag.is_set():
                return
            time.sleep(1)


def _tick_loop() -> None:
    """
    Background thread.
    Fetches the current BGS tick from tick.edcd.io every TICK_INTERVAL_S
    seconds so journal events can be enriched with the correct tickid/ticktime.
    """
    global _tick_id, _tick_time

    while not _stop_flag.is_set():
        try:
            resp = requests.get(TICK_API_URL, timeout=10)
            if resp.status_code == 200:
                data = resp.json()
                new_tick_id = str(data.get('tickId') or data.get('id') or '')
                with _lock:
                    old_tick = _tick_id
                    _tick_id   = new_tick_id
                    _tick_time = data.get('time', '')
                # Reset PP session totals when a new BGS tick starts
                if new_tick_id and new_tick_id != old_tick and old_tick:
                    with _pp_lock:
                        global _pp_merits_gained, _pp_activity
                        _pp_merits_gained = 0
                        _pp_activity = {k: 0 for k in _pp_activity}
        except Exception:
            pass  # keep last known tick on failure

        # Sleep in 1-second increments so _stop_flag is checked promptly
        for _ in range(TICK_INTERVAL_S):
            if _stop_flag.is_set():
                return
            time.sleep(1)


# ── ETNF: Config helpers ──────────────────────────────────────────────────────

def _etnf_default_config() -> dict:
    return {'local_port': ETNF_DEFAULT_PORT, 'local_key': secrets.token_hex(20), 'targets': []}


def _etnf_load_config(plugin_dir: str) -> dict:
    path = os.path.join(plugin_dir, ETNF_CONFIG_FILE)
    if os.path.exists(path):
        try:
            with open(path, 'r', encoding='utf-8') as f:
                cfg = json.load(f)
            cfg.setdefault('local_port', ETNF_DEFAULT_PORT)
            cfg.setdefault('targets', [])
            if not cfg.get('local_key'):
                cfg['local_key'] = secrets.token_hex(20)
            return cfg
        except Exception:
            pass
    cfg = _etnf_default_config()
    _etnf_save_config(plugin_dir, cfg)
    return cfg


def _etnf_save_config(plugin_dir: str, cfg: dict) -> None:
    with open(os.path.join(plugin_dir, ETNF_CONFIG_FILE), 'w', encoding='utf-8') as f:
        json.dump(cfg, f, indent=2)


# ── ETNF: Per-type forwarding logic ──────────────────────────────────────────

def _etnf_dispatch(target: dict, method: str, endpoint: str, raw: bytes, parsed: dict) -> tuple:
    """Route to the correct sender for this target type. Returns (ok, message)."""
    t = target.get('type', 'bgstally')
    if t == 'bgstally':
        return _etnf_send_bgstally(target, method, endpoint, raw)
    if t == 'custom_api':
        return _etnf_send_custom_api(target, endpoint, raw)
    if t == 'webhook':
        return _etnf_send_webhook(target, endpoint, parsed)
    if t == 'discord':
        return _etnf_send_discord(target, endpoint, parsed)
    return False, f"Unknown type: {t}"


def _etnf_send_bgstally(target: dict, method: str, endpoint: str, raw: bytes) -> tuple:
    url     = f"{target['url'].rstrip('/')}/t/{target['slug'].strip('/')}/bgstally/{endpoint}"
    headers = {'Content-Type': 'application/json', 'apikey': target['key']}
    fn      = requests.put if method == 'PUT' else requests.post
    r       = fn(url, data=raw, headers=headers, timeout=ETNF_FORWARD_TIMEOUT)
    return r.status_code in (200, 201, 204), f"HTTP {r.status_code}"


def _etnf_send_custom_api(target: dict, endpoint: str, raw: bytes) -> tuple:
    """Forward to a BGS-compatible server with a custom URL structure and auth scheme."""
    if endpoint not in target.get('forward', ['activities', 'events']):
        return True, 'skipped'

    url_tpl = target.get('url_template', '')
    if not url_tpl:
        return False, 'url_template not configured'
    url = url_tpl.replace('{endpoint}', endpoint)

    auth_header = target.get('auth_header', 'apikey')
    auth_value  = target.get('auth_value',  '')
    headers = {'Content-Type': 'application/json', auth_header: auth_value}

    if endpoint == 'activities':
        method = target.get('method_activities', 'PUT').upper()
    else:
        method = target.get('method_events', 'POST').upper()

    fn = requests.put if method == 'PUT' else requests.post
    r  = fn(url, data=raw, headers=headers, timeout=ETNF_FORWARD_TIMEOUT)
    return r.status_code in (200, 201, 204), f"HTTP {r.status_code}"


def _etnf_send_webhook(target: dict, endpoint: str, parsed: dict) -> tuple:
    if endpoint not in target.get('forward', ['activities', 'events']):
        return True, 'skipped'
    method  = target.get('method', 'POST').upper()
    headers = dict(target.get('headers', {}))
    headers.setdefault('Content-Type', 'application/json')
    fn = getattr(requests, method.lower(), requests.post)
    r  = fn(target['url'], json=parsed, headers=headers, timeout=ETNF_FORWARD_TIMEOUT)
    return r.ok, f"HTTP {r.status_code}"


def _etnf_send_discord(target: dict, endpoint: str, parsed: dict) -> tuple:
    if endpoint not in target.get('forward', ['activities']):
        return True, 'skipped'
    embed = _etnf_discord_embed(endpoint, parsed)
    r     = requests.post(target['url'], json={'embeds': [embed]}, timeout=ETNF_FORWARD_TIMEOUT)
    return r.status_code in (200, 204), f"HTTP {r.status_code}"


def _etnf_discord_embed(endpoint: str, body: dict) -> dict:
    if endpoint == 'activities':
        return _etnf_discord_activities(body)
    return _etnf_discord_events(body if isinstance(body, list) else [])


def _etnf_discord_activities(body: dict) -> dict:
    cmdr    = body.get('cmdr', 'Unknown CMDR')
    tick    = body.get('tickid', '—')
    systems = body.get('systems', [])
    lines   = []
    for sys in systems[:5]:
        sname = sys.get('name', '?')
        lines.append(f"**{sname}**")
        for f in sys.get('factions', [])[:6]:
            inf  = int(f.get('infprimary', 0))
            bvs  = int(f.get('bvs', 0))
            cbs  = int(f.get('cbs', 0))
            parts = []
            if inf: parts.append(f"inf +{inf}")
            if bvs: parts.append(f"bvs {bvs:,}")
            if cbs: parts.append(f"cbs {cbs:,}")
            for cz_key in ('czspace', 'czground'):
                cz = f.get(cz_key, {})
                for grade, label in [('high','H'),('medium','M'),('low','L')]:
                    n = cz.get(grade, 0)
                    if n: parts.append(f"CZ{label}x{n}")
            if parts:
                lines.append(f"  > {f.get('name','?')}: {', '.join(parts)}")
    return {
        'title':       f"BGS Activity — {cmdr}",
        'description': '\n'.join(lines) or 'No activity data.',
        'color':       0x00D4FF,
        'footer':      {'text': f"Tick {tick} · ETNF"},
        'timestamp':   body.get('timestamp', ''),
    }


def _etnf_discord_events(events: list) -> dict:
    lines = []
    for ev in events[:10]:
        name   = ev.get('event', '?')
        system = ev.get('StarSystem', '')
        cmdr   = ev.get('cmdr', '')
        parts  = [f"`{name}`"]
        if system: parts.append(system)
        if cmdr:   parts.append(f"by {cmdr}")
        lines.append(' — '.join(parts))
    return {
        'title':       'BGS Journal Events',
        'description': '\n'.join(lines) or 'No events.',
        'color':       0xFFD700,
        'footer':      {'text': 'ETNF'},
    }


def _etnf_target_url(target: dict, endpoint: str) -> str:
    """Return the effective URL for a given target and endpoint."""
    t_type = target.get('type', 'bgstally')
    if t_type == 'bgstally':
        return f"{target['url'].rstrip('/')}/t/{target['slug'].strip('/')}/bgstally/{endpoint}"
    if t_type == 'custom_api':
        tpl = target.get('url_template', '')
        return tpl.replace('{endpoint}', endpoint) if tpl else ''
    return target.get('url', '')


# ── ETNF: HTTP proxy server ───────────────────────────────────────────────────

class _EtnfHandler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args): pass  # silence HTTP log

    def _tail(self) -> str:
        return urlparse(self.path).path.rstrip('/').rsplit('/', 1)[-1]

    def _auth(self) -> bool:
        key = self.headers.get('apikey', '')
        if not _etnf_config.get('local_key') or key != _etnf_config['local_key']:
            self._json(401, {'error': 'Invalid or missing API key'})
            return False
        return True

    def _body(self) -> bytes:
        n = int(self.headers.get('Content-Length', 0))
        return self.rfile.read(n) if n > 0 else b''

    def _json(self, code: int, data) -> None:
        body = json.dumps(data).encode()
        self.send_response(code)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _active(self) -> list:
        return [t for t in _etnf_config.get('targets', []) if t.get('enabled', True)]

    def _bgstally_targets(self) -> list:
        """BGS Portal and Custom API targets both support GET endpoints."""
        return [t for t in self._active() if t.get('type', 'bgstally') in ('bgstally', 'custom_api')]

    def do_GET(self):
        tail = self._tail()
        if tail == 'discovery':
            self._discovery()
        elif tail == 'objectives':
            if self._auth(): self._proxy_get('objectives', {'ok': True, 'objectives': []})
        elif tail == 'overlay-data':
            if self._auth(): self._proxy_get('overlay-data', {'tally': {}, 'pp': {}, 'orders': []})
        else:
            self._json(404, {'error': 'Not found'})

    def do_PUT(self):
        if self._tail() == 'activities' and self._auth():
            body = self._body()
            self._fan_out('PUT', 'activities', body)
            self._json(200, {'ok': True})
        else:
            self._json(404, {'error': 'Not found'})

    def do_POST(self):
        if self._tail() == 'events' and self._auth():
            body = self._body()
            self._fan_out('POST', 'events', body)
            self._json(200, {'ok': True})
        else:
            self._json(404, {'error': 'Not found'})

    def _discovery(self) -> None:
        n = len(self._active())
        self._json(200, {
            'name':        f'ETNF — Elite Tactical Network Forwarder v{ETNF_PLUGIN_VERSION}',
            'version':     API_VERSION,
            'description': f'Forwarding to {n} active target(s). Configure in EDMC -> Settings -> EliteBGSPortal.',
            'events':      ADVERTISED_EVENTS,
            'endpoints': {
                'activities': {'path': 'activities', 'min_period': 60,  'max_batch': None},
                'events':     {'path': 'events',     'min_period': 5,   'max_batch': 10},
                'objectives': {'path': 'objectives', 'min_period': 30,  'max_batch': None},
            },
        })

    def _proxy_get(self, endpoint: str, fallback: dict) -> None:
        targets = self._bgstally_targets()
        if not targets:
            self._json(200, fallback)
            return
        t = targets[0]
        try:
            t_type = t.get('type', 'bgstally')
            if t_type == 'custom_api':
                tpl = t.get('url_template', '')
                url = tpl.replace('{endpoint}', endpoint) if tpl else ''
                auth_h = t.get('auth_header', 'apikey')
                auth_v = t.get('auth_value',  '')
                hdrs   = {auth_h: auth_v}
            else:
                url  = f"{t['url'].rstrip('/')}/t/{t['slug'].strip('/')}/bgstally/{endpoint}"
                hdrs = {'apikey': t['key']}
            resp = requests.get(url, headers=hdrs, timeout=ETNF_FORWARD_TIMEOUT)
            self._json(resp.status_code, resp.json())
        except Exception:
            self._json(200, fallback)

    def _fan_out(self, method: str, endpoint: str, raw: bytes) -> None:
        targets = self._active()
        if not targets:
            return
        parsed: dict = {}
        if raw:
            try:
                parsed = json.loads(raw)
            except Exception:
                pass

        errors:  list = []
        success: list = []
        lock = threading.Lock()

        def send(t: dict) -> None:
            try:
                ok, msg = _etnf_dispatch(t, method, endpoint, raw, parsed)
                with lock:
                    (success if ok else errors).append(
                        t['name'] if ok else f"{t['name']}: {msg}"
                    )
            except Exception as exc:
                with lock:
                    errors.append(f"{t['name']}: {str(exc)[:60]}")

        threads = [threading.Thread(target=send, args=(t,), daemon=True) for t in targets]
        for th in threads: th.start()
        for th in threads: th.join(timeout=ETNF_FORWARD_TIMEOUT + 2)

        if _etnf_status_var:
            if errors:
                _etnf_status_var.set(f"Errors: {'; '.join(errors)}")
            elif success:
                _etnf_status_var.set(f"Forwarded {endpoint} -> {', '.join(success)}")


def _etnf_start_proxy() -> None:
    global _etnf_server
    port = int(_etnf_config.get('local_port', ETNF_DEFAULT_PORT))
    try:
        _etnf_server = HTTPServer(('127.0.0.1', port), _EtnfHandler)
        _etnf_server.serve_forever()
    except OSError as exc:
        print(f"[EliteBGSPortal/ETNF] Cannot bind on port {port}: {exc}")
    except Exception as exc:
        print(f"[EliteBGSPortal/ETNF] Proxy error: {exc}")


# ── ETNF: Fan-out from _sender_loop ──────────────────────────────────────────

def _etnf_fan_out_events(batch: list) -> None:
    """
    Fan out a raw events batch to all enabled ETNF targets.
    Fire-and-forget; errors are silently discarded.
    """
    targets = [t for t in _etnf_config.get('targets', []) if t.get('enabled', True)]
    if not targets or not batch:
        return
    raw = json.dumps(batch).encode()

    def _send_one(t: dict) -> None:
        try:
            _etnf_dispatch(t, 'POST', 'events', raw, batch)
        except Exception:
            pass

    for t in targets:
        threading.Thread(target=_send_one, args=(t,), daemon=True).start()


# ── ETNF: Count / refresh helper ─────────────────────────────────────────────

def _etnf_refresh_count() -> None:
    if _etnf_count_var is None:
        return
    port   = _etnf_config.get('local_port', ETNF_DEFAULT_PORT)
    active = sum(1 for t in _etnf_config.get('targets', []) if t.get('enabled', True))
    total  = len(_etnf_config.get('targets', []))
    _etnf_count_var.set(f"ETNF  :{port}  {active}/{total} targets active")


# ── ETNF: Prefs UI section ────────────────────────────────────────────────────

def _etnf_prefs_section(frame: tk.Frame, start_row: int, dlg_parent: tk.Widget) -> None:
    """
    Build the ETNF prefs UI into *frame* starting at *start_row*.
    *dlg_parent* is used as the parent for all messageboxes so they appear
    centred over the EDMC settings dialog rather than the hidden canvas.
    """
    global _etnf_status_var, _etnf_count_var

    # Ensure the frame's columns are configured for 4-column layout
    frame.columnconfigure(0, weight=0)
    frame.columnconfigure(1, weight=1)
    frame.columnconfigure(2, weight=0)
    frame.columnconfigure(3, weight=0)

    _etnf_status_var = tk.StringVar(value='Ready')
    _etnf_count_var  = tk.StringVar()
    _etnf_refresh_count()

    row = start_row

    # ── Header ────────────────────────────────────────────────────────────────
    tk.Label(frame, text=f"ETNF — Elite Tactical Network Forwarder  v{ETNF_PLUGIN_VERSION}",
             font=('TkDefaultFont', 11, 'bold')).grid(
        row=row, column=0, columnspan=4, sticky='w', padx=6, pady=(8, 1))
    row += 1

    tk.Label(frame,
             text="Point BGS Tally at the local proxy below, then add forward targets.",
             foreground='gray').grid(
        row=row, column=0, columnspan=4, sticky='w', padx=6, pady=(0, 6))
    row += 1

    # ── BGS Tally proxy credentials (read-only + Copy) ────────────────────────
    tk.Label(frame, text="Step 1 — Configure BGS Tally to use these settings:",
             font=('TkDefaultFont', 9, 'bold')).grid(
        row=row, column=0, columnspan=4, sticky='w', padx=6)
    row += 1

    port      = _etnf_config.get('local_port', ETNF_DEFAULT_PORT)
    local_key = _etnf_config.get('local_key', '')

    def _ro_row(r, label, value, mono=False, show_copy=True):
        tk.Label(frame, text=label, anchor='w').grid(
            row=r, column=0, sticky='w', padx=6, pady=2)
        v   = tk.StringVar(value=value)
        fnt = ('Courier', 9) if mono else None
        kw  = {'font': fnt} if fnt else {}
        ent = tk.Entry(frame, textvariable=v, state='readonly',
                       readonlybackground='#eeeeee', width=42, **kw)
        ent.grid(row=r, column=1, columnspan=2, sticky='ew', padx=2)
        if show_copy:
            tk.Button(frame, text='Copy', width=5,
                      command=lambda val=v: _clip(dlg_parent, val.get())
                      ).grid(row=r, column=3, padx=(4, 6))

    _ro_row(row, 'Server URL:',
            f'http://localhost:{port}')
    row += 1
    _ro_row(row, 'Slug:',
            'etnf  (ETNF ignores this — any value works)', show_copy=False)
    row += 1
    _ro_row(row, 'API Key:', local_key, mono=True)
    row += 1

    # Port editor
    tk.Label(frame, text='Port:', anchor='w').grid(
        row=row, column=0, sticky='w', padx=6, pady=2)
    port_var = tk.StringVar(value=str(port))
    tk.Entry(frame, textvariable=port_var, width=7).grid(
        row=row, column=1, sticky='w', padx=2)
    tk.Label(frame, text='restart EDMC after changing',
             foreground='gray', font=('TkDefaultFont', 8)).grid(
        row=row, column=2, sticky='w', padx=4)

    def _save_port():
        try:
            p = int(port_var.get())
            if not (1024 < p < 65536):
                raise ValueError
            _etnf_config['local_port'] = p
            _etnf_save_config(_plugin_dir, _etnf_config)
            messagebox.showinfo('ETNF', f'Port saved to {p}.\nRestart EDMC to apply.',
                                parent=dlg_parent)
        except ValueError:
            messagebox.showerror('ETNF', 'Port must be a number between 1025 and 65535.',
                                 parent=dlg_parent)

    tk.Button(frame, text='Save port', command=_save_port).grid(
        row=row, column=3, padx=(4, 6), sticky='w')
    row += 1

    tk.Frame(frame, height=1, bg='#cccccc').grid(
        row=row, column=0, columnspan=4, sticky='ew', padx=6, pady=(10, 6))
    row += 1

    # ── Target list ───────────────────────────────────────────────────────────
    tk.Label(frame, text='Step 2 — Forward targets:',
             font=('TkDefaultFont', 9, 'bold')).grid(
        row=row, column=0, columnspan=4, sticky='w', padx=6, pady=(0, 4))
    row += 1

    list_outer = tk.Frame(frame, relief='sunken', bd=1)
    list_outer.grid(row=row, column=0, columnspan=4, sticky='nsew', padx=6, pady=(0, 4))
    list_outer.columnconfigure(0, weight=1)

    sb = tk.Scrollbar(list_outer, orient='vertical')
    sb.pack(side='right', fill='y')
    lb = tk.Listbox(list_outer, yscrollcommand=sb.set, height=6,
                    selectmode='single', activestyle='dotbox',
                    font=('Courier', 9))
    lb.pack(side='left', fill='both', expand=True)
    sb.config(command=lb.yview)
    row += 1

    def _refresh_lb():
        lb.delete(0, 'end')
        for t in _etnf_config.get('targets', []):
            en    = t.get('enabled', True)
            icon  = '[ON] ' if en else '[OFF]'
            ttype = TARGET_TYPE_LABELS.get(t.get('type', 'bgstally'), '?')
            name  = t.get('name', '?')
            if t.get('type', 'bgstally') == 'bgstally':
                dest = f"{t.get('url','')}/t/{t.get('slug','')}/bgstally/"
            elif t.get('type') == 'custom_api':
                dest = t.get('url_template', '—')
            else:
                dest = t.get('url', '—')
            lb.insert('end', f" {icon} [{ttype:<10}]  {name:<18}  {dest}")
            lb.itemconfig('end', foreground='black' if en else '#999999')
        _etnf_refresh_count()

    _refresh_lb()

    # ── Action buttons ────────────────────────────────────────────────────────
    btn_frame = tk.Frame(frame)
    btn_frame.grid(row=row, column=0, columnspan=4, sticky='w', padx=6, pady=(0, 6))
    row += 1

    def _sel():
        sel = lb.curselection()
        if not sel:
            return None, -1
        idx     = sel[0]
        targets = _etnf_config.get('targets', [])
        return (targets[idx] if 0 <= idx < len(targets) else None), idx

    def _add():
        _etnf_dlg_target(dlg_parent, None, _refresh_lb)

    def _edit():
        t, _ = _sel()
        if t is None:
            messagebox.showinfo('ETNF', 'Select a target first.', parent=dlg_parent)
            return
        _etnf_dlg_target(dlg_parent, t, _refresh_lb)

    def _toggle():
        t, _ = _sel()
        if t:
            t['enabled'] = not t.get('enabled', True)
            _etnf_save_config(_plugin_dir, _etnf_config)
            _refresh_lb()

    def _remove():
        t, idx = _sel()
        if t is None:
            messagebox.showinfo('ETNF', 'Select a target first.', parent=dlg_parent)
            return
        if messagebox.askyesno('ETNF', f"Remove '{t['name']}'?", parent=dlg_parent):
            _etnf_config['targets'].pop(idx)
            _etnf_save_config(_plugin_dir, _etnf_config)
            _refresh_lb()

    def _test():
        t, _ = _sel()
        if t is None:
            messagebox.showinfo('ETNF', 'Select a target to test.', parent=dlg_parent)
            return
        _etnf_status_var.set(f"Testing {t['name']} ...")
        dlg_parent.update_idletasks()
        _etnf_test_connection(dlg_parent, t, _etnf_status_var)

    for lbl, cmd in [
        ('Add target',       _add),
        ('Edit',             _edit),
        ('Enable / Disable', _toggle),
        ('Remove',           _remove),
        ('Test connection',  _test),
    ]:
        tk.Button(btn_frame, text=lbl, command=cmd).pack(side='left', padx=3)

    # ── Status line ───────────────────────────────────────────────────────────
    tk.Frame(frame, height=1, bg='#cccccc').grid(
        row=row, column=0, columnspan=4, sticky='ew', padx=6, pady=(4, 2))
    row += 1

    tk.Label(frame, text='Last action:', anchor='w').grid(
        row=row, column=0, sticky='w', padx=6, pady=(2, 8))
    tk.Label(frame, textvariable=_etnf_status_var, foreground='#005999',
             wraplength=380, justify='left').grid(
        row=row, column=1, columnspan=3, sticky='w', pady=(2, 8))


# ── ETNF: Test connection (type-aware) ────────────────────────────────────────

def _etnf_test_connection(parent: tk.Widget, t: dict, status: tk.StringVar) -> None:
    t_type = t.get('type', 'bgstally')
    try:
        if t_type == 'bgstally':
            url  = f"{t['url'].rstrip('/')}/t/{t['slug'].strip('/')}/bgstally/discovery"
            resp = requests.get(url, timeout=7)
            data = resp.json()
            name = data.get('name', 'Unknown')
            ver  = data.get('version', '?')
            status.set(f"OK {t['name']} — {name} (v{ver})")
            messagebox.showinfo("ETNF — Connected",
                                f"Target : {t['name']}\n"
                                f"Server : {name}\n"
                                f"API ver: v{ver}\n"
                                f"URL    : {t['url']}/t/{t['slug']}/",
                                parent=parent)

        elif t_type == 'custom_api':
            tpl       = t.get('url_template', '')
            auth_h    = t.get('auth_header', 'apikey')
            auth_v    = t.get('auth_value',  '')
            disc_url  = tpl.replace('{endpoint}', 'discovery')
            try:
                resp = requests.get(disc_url, headers={auth_h: auth_v}, timeout=7)
                if resp.status_code == 200:
                    data = resp.json()
                    srv  = data.get('name', '(no name)')
                    ver  = data.get('version', '?')
                    status.set(f"OK {t['name']} — discovery OK ({srv} v{ver})")
                    messagebox.showinfo("ETNF — Connected",
                                        f"Target : {t['name']}\n"
                                        f"Server : {srv}\n"
                                        f"API ver: v{ver}\n"
                                        f"URL    : {disc_url}",
                                        parent=parent)
                    return
            except Exception:
                pass
            act_url = tpl.replace('{endpoint}', 'activities')
            resp    = requests.head(act_url, headers={auth_h: auth_v}, timeout=7)
            status.set(f"OK {t['name']} — reachable (HTTP {resp.status_code})")
            messagebox.showinfo("ETNF — Reachable",
                                f"Target      : {t['name']}\n"
                                f"URL template: {tpl}\n"
                                f"HEAD test   : HTTP {resp.status_code}\n\n"
                                "Discovery endpoint not found; reachability only confirmed.",
                                parent=parent)

        elif t_type == 'webhook':
            resp = requests.head(t['url'], headers=dict(t.get('headers', {})), timeout=7)
            status.set(f"OK {t['name']} — reachable (HTTP {resp.status_code})")
            messagebox.showinfo("ETNF — Reachable",
                                f"Target: {t['name']}\n"
                                f"URL   : {t['url']}\n"
                                f"HTTP  : {resp.status_code}\n\n"
                                "A HEAD request was used to test reachability.\n"
                                "The server may still reject POST/PUT at runtime.",
                                parent=parent)

        elif t_type == 'discord':
            resp = requests.get(t['url'], timeout=7)
            if resp.status_code == 200:
                data    = resp.json()
                wh_name = data.get('name', '?')
                channel = data.get('channel_id', '?')
                status.set(f"OK {t['name']} — Discord webhook OK ({wh_name})")
                messagebox.showinfo("ETNF — Connected",
                                    f"Target    : {t['name']}\n"
                                    f"Webhook   : {wh_name}\n"
                                    f"Channel ID: {channel}",
                                    parent=parent)
            else:
                status.set(f"Discord returned HTTP {resp.status_code}")
                messagebox.showwarning("ETNF", f"Discord returned HTTP {resp.status_code}", parent=parent)

    except Exception as exc:
        status.set(f"FAIL {t['name']} — {str(exc)[:80]}")
        messagebox.showerror("ETNF — Failed",
                             f"Could not reach '{t['name']}':\n\n{exc}", parent=parent)


# ── ETNF: Add / Edit target dialog ────────────────────────────────────────────

def _clip(root: tk.Widget, text: str) -> None:
    root.clipboard_clear()
    root.clipboard_append(text)


def _etnf_dlg_target(parent: tk.Widget, target: Optional[dict], refresh_cb: Callable) -> None:
    editing = target is not None
    dlg = tk.Toplevel(parent)
    dlg.title("Edit target" if editing else "Add forward target")
    dlg.resizable(False, False)
    dlg.grab_set()
    dlg.columnconfigure(1, weight=1)

    # ── Display name (always visible) ─────────────────────────────────────────
    tk.Label(dlg, text="Display name:").grid(row=0, column=0, sticky='w', padx=10, pady=5)
    name_var = tk.StringVar(value=target.get('name', '') if editing else '')
    tk.Entry(dlg, textvariable=name_var, width=40).grid(
        row=0, column=1, columnspan=2, sticky='ew', padx=10, pady=5)

    # ── Type selector ─────────────────────────────────────────────────────────
    tk.Label(dlg, text="Target type:").grid(row=1, column=0, sticky='w', padx=10, pady=5)
    type_var = tk.StringVar(value=target.get('type', 'bgstally') if editing else 'bgstally')
    type_cb  = ttk.Combobox(dlg, textvariable=type_var,
                            values=['bgstally', 'custom_api', 'webhook', 'discord'],
                            state='readonly', width=18)
    type_cb.grid(row=1, column=1, sticky='w', padx=10)

    # Type description label
    desc_var = tk.StringVar()
    tk.Label(dlg, textvariable=desc_var, foreground='#555555',
             wraplength=400, justify='left', font=('TkDefaultFont', 8)).grid(
        row=2, column=0, columnspan=3, sticky='w', padx=10, pady=(0, 6))

    ttk.Separator(dlg, orient='horizontal').grid(
        row=3, column=0, columnspan=3, sticky='ew', padx=10, pady=4)

    # ── Type-specific fields (rebuilt on type change) ─────────────────────────
    content = tk.Frame(dlg)
    content.grid(row=4, column=0, columnspan=3, sticky='nsew', padx=4)
    content.columnconfigure(1, weight=1)

    _collector: list = [None]

    def rebuild(*_):
        for w in content.winfo_children():
            w.destroy()
        t = type_var.get()
        desc_var.set(TARGET_TYPE_DESCS.get(t, ''))
        if t == 'bgstally':
            _collector[0] = _etnf_fields_bgstally(content, target)
        elif t == 'custom_api':
            _collector[0] = _etnf_fields_custom_api(content, target)
        elif t == 'webhook':
            _collector[0] = _etnf_fields_webhook(content, target)
        elif t == 'discord':
            _collector[0] = _etnf_fields_discord(content, target)
        dlg.update_idletasks()

    type_cb.bind('<<ComboboxSelected>>', rebuild)
    rebuild()

    ttk.Separator(dlg, orient='horizontal').grid(
        row=5, column=0, columnspan=3, sticky='ew', padx=10, pady=8)

    # ── Save / Cancel ─────────────────────────────────────────────────────────
    def save():
        name = name_var.get().strip()
        if not name:
            messagebox.showerror("ETNF", "Display name is required.", parent=dlg)
            return
        collector = _collector[0]
        if collector is None:
            return
        try:
            data = collector()
        except ValueError as exc:
            messagebox.showerror("ETNF", str(exc), parent=dlg)
            return
        data['name']    = name
        data['type']    = type_var.get()
        data['enabled'] = target.get('enabled', True) if editing else True
        if editing:
            target.clear()
            target.update(data)
        else:
            _etnf_config.setdefault('targets', []).append(data)
        _etnf_save_config(_plugin_dir, _etnf_config)
        refresh_cb()
        dlg.destroy()

    btn_frame = tk.Frame(dlg)
    btn_frame.grid(row=6, column=0, columnspan=3, sticky='ew', padx=10, pady=(0, 10))
    tk.Button(btn_frame, text="Save",   command=save,        width=12).pack(side='right', padx=4)
    tk.Button(btn_frame, text="Cancel", command=dlg.destroy, width=12).pack(side='right')


# ── ETNF: Per-type field builders ────────────────────────────────────────────

def _etnf_fields_bgstally(frame: tk.Frame, target: Optional[dict]) -> Callable:
    t = target or {}
    url_var  = tk.StringVar(value=t.get('url',  ''))
    slug_var = tk.StringVar(value=t.get('slug', ''))
    key_var  = tk.StringVar(value=t.get('key',  ''))

    _etnf_field_row(frame, 0, "Server URL (https://...):", url_var,  width=44)
    _etnf_field_row(frame, 1, "Squadron slug:",            slug_var, width=24)
    _etnf_field_row(frame, 2, "API key:",                  key_var,  width=44, secret=True)

    def collect() -> dict:
        url  = url_var.get().strip().rstrip('/')
        slug = slug_var.get().strip().strip('/')
        key  = key_var.get().strip()
        if not url or not slug or not key:
            raise ValueError("Server URL, squadron slug, and API key are all required.")
        if not url.startswith('http'):
            raise ValueError("Server URL must start with http:// or https://")
        return {'url': url, 'slug': slug, 'key': key}

    return collect


def _etnf_fields_custom_api(frame: tk.Frame, target: Optional[dict]) -> Callable:
    """Custom API Server fields: URL template, auth header, methods, forward filter."""
    t = target or {}
    tpl_var   = tk.StringVar(value=t.get('url_template',      ''))
    hdr_var   = tk.StringVar(value=t.get('auth_header',       'apikey'))
    val_var   = tk.StringVar(value=t.get('auth_value',        ''))
    mact_var  = tk.StringVar(value=t.get('method_activities', 'PUT'))
    mevt_var  = tk.StringVar(value=t.get('method_events',     'POST'))
    fwd       = t.get('forward', ['activities', 'events'])
    fwd_act   = tk.BooleanVar(value='activities' in fwd)
    fwd_evt   = tk.BooleanVar(value='events' in fwd)

    # URL template
    tk.Label(frame, text="URL template:").grid(row=0, column=0, sticky='w', padx=10, pady=3)
    tpl_ent = tk.Entry(frame, textvariable=tpl_var, width=52)
    tpl_ent.grid(row=0, column=1, columnspan=2, sticky='w', padx=6, pady=3)

    # Live preview
    preview_var = tk.StringVar()
    tk.Label(frame, text="Preview:").grid(row=1, column=0, sticky='w', padx=10)
    tk.Label(frame, textvariable=preview_var, foreground='#005999',
             font=('Courier', 8), anchor='w').grid(row=1, column=1, columnspan=2, sticky='w', padx=6)

    def update_preview(*_):
        tpl = tpl_var.get().strip()
        preview_var.set(tpl.replace('{endpoint}', 'activities') if tpl else '—')

    tpl_var.trace_add('write', update_preview)
    update_preview()

    tk.Label(frame,
             text="Use {endpoint} where the path segment should go.\n"
                  "Example: https://comstar.example.com/api/bgs/{endpoint}\n"
                  "         https://myserver.com/squadron/TAGNAME/{endpoint}",
             foreground='#555', font=('TkDefaultFont', 8), justify='left').grid(
        row=2, column=0, columnspan=3, sticky='w', padx=10, pady=(0, 4))

    ttk.Separator(frame, orient='horizontal').grid(
        row=3, column=0, columnspan=3, sticky='ew', padx=6, pady=4)

    # Auth header name + value
    tk.Label(frame, text="Auth header:").grid(row=4, column=0, sticky='w', padx=10, pady=3)
    tk.Entry(frame, textvariable=hdr_var, width=20).grid(row=4, column=1, sticky='w', padx=6)
    tk.Label(frame, text="e.g. apikey  Authorization  X-API-Key",
             foreground='#666', font=('TkDefaultFont', 8)).grid(
        row=4, column=2, sticky='w', padx=4)

    _etnf_field_row(frame, 5, "Auth value:", val_var, width=44, secret=True)

    tk.Label(frame,
             text="If the server uses Bearer tokens set header to 'Authorization'\n"
                  "and value to 'Bearer <your-token>'.",
             foreground='#555', font=('TkDefaultFont', 8), justify='left').grid(
        row=6, column=0, columnspan=3, sticky='w', padx=10, pady=(0, 4))

    ttk.Separator(frame, orient='horizontal').grid(
        row=7, column=0, columnspan=3, sticky='ew', padx=6, pady=4)

    # HTTP methods
    tk.Label(frame, text="Activities method:").grid(row=8, column=0, sticky='w', padx=10, pady=3)
    ttk.Combobox(frame, textvariable=mact_var, values=['PUT', 'POST', 'PATCH'],
                 state='readonly', width=8).grid(row=8, column=1, sticky='w', padx=6)

    tk.Label(frame, text="Events method:").grid(row=9, column=0, sticky='w', padx=10, pady=3)
    ttk.Combobox(frame, textvariable=mevt_var, values=['POST', 'PUT', 'PATCH'],
                 state='readonly', width=8).grid(row=9, column=1, sticky='w', padx=6)

    # Forward filter
    tk.Label(frame, text="Forward:").grid(row=10, column=0, sticky='w', padx=10, pady=3)
    fwd_f = tk.Frame(frame)
    fwd_f.grid(row=10, column=1, columnspan=2, sticky='w')
    tk.Checkbutton(fwd_f, text="Activities", variable=fwd_act).pack(side='left')
    tk.Checkbutton(fwd_f, text="Events",     variable=fwd_evt).pack(side='left', padx=12)

    def collect() -> dict:
        tpl = tpl_var.get().strip()
        if not tpl:
            raise ValueError("URL template is required.")
        if '{endpoint}' not in tpl:
            raise ValueError("URL template must contain {endpoint}.\n"
                             "Example: https://server.com/api/bgs/{endpoint}")
        if not tpl.startswith('http'):
            raise ValueError("URL template must start with http:// or https://")
        hdr = hdr_var.get().strip()
        val = val_var.get().strip()
        if not hdr:
            raise ValueError("Auth header name is required (e.g. 'apikey').")
        forward = [ep for ep, on in [('activities', fwd_act), ('events', fwd_evt)] if on.get()]
        if not forward:
            raise ValueError("Enable at least one endpoint to forward.")
        return {
            'url_template':      tpl,
            'auth_header':       hdr,
            'auth_value':        val,
            'method_activities': mact_var.get(),
            'method_events':     mevt_var.get(),
            'forward':           forward,
        }

    return collect


def _etnf_fields_webhook(frame: tk.Frame, target: Optional[dict]) -> Callable:
    t = target or {}
    url_var  = tk.StringVar(value=t.get('url',    ''))
    meth_var = tk.StringVar(value=t.get('method', 'POST'))
    fwd      = t.get('forward', ['activities', 'events'])
    fwd_act  = tk.BooleanVar(value='activities' in fwd)
    fwd_evt  = tk.BooleanVar(value='events' in fwd)

    _etnf_field_row(frame, 0, "Endpoint URL:", url_var, width=48)

    tk.Label(frame, text="HTTP method:").grid(row=1, column=0, sticky='w', padx=10, pady=3)
    ttk.Combobox(frame, textvariable=meth_var, values=['POST', 'PUT', 'PATCH'],
                 state='readonly', width=10).grid(row=1, column=1, sticky='w', padx=6)

    tk.Label(frame, text="Forward:").grid(row=2, column=0, sticky='w', padx=10, pady=3)
    fwd_f = tk.Frame(frame)
    fwd_f.grid(row=2, column=1, sticky='w')
    tk.Checkbutton(fwd_f, text="Activities (PUT, every ~60 s)", variable=fwd_act).pack(side='left')
    tk.Checkbutton(fwd_f, text="Events (POST, every 5 s)",     variable=fwd_evt).pack(side='left', padx=12)

    tk.Label(frame, text="Custom headers:").grid(row=3, column=0, sticky='nw', padx=10, pady=3)
    tk.Label(frame, text="One per line:  Header-Name: value",
             foreground='#666', font=('TkDefaultFont', 8)).grid(
        row=3, column=1, sticky='w', padx=6)
    existing = '\n'.join(f"{k}: {v}" for k, v in t.get('headers', {}).items())
    hdr_box  = tk.Text(frame, width=48, height=4, font=('Courier', 9))
    hdr_box.grid(row=4, column=1, sticky='w', padx=6, pady=3)
    if existing:
        hdr_box.insert('1.0', existing)

    def collect() -> dict:
        url = url_var.get().strip()
        if not url:
            raise ValueError("Endpoint URL is required.")
        if not url.startswith('http'):
            raise ValueError("URL must start with http:// or https://")
        headers: dict = {}
        for line in hdr_box.get('1.0', 'end').splitlines():
            line = line.strip()
            if not line:
                continue
            if ':' not in line:
                raise ValueError(f"Invalid header (expected 'Name: value'):\n  {line}")
            k, v = line.split(':', 1)
            headers[k.strip()] = v.strip()
        forward = ([ep for ep, on in [('activities', fwd_act), ('events', fwd_evt)] if on.get()])
        if not forward:
            raise ValueError("Enable at least one data type to forward (Activities or Events).")
        return {'url': url, 'method': meth_var.get(), 'headers': headers, 'forward': forward}

    return collect


def _etnf_fields_discord(frame: tk.Frame, target: Optional[dict]) -> Callable:
    t   = target or {}
    url_var = tk.StringVar(value=t.get('url', ''))
    fwd     = t.get('forward', ['activities'])
    fwd_act = tk.BooleanVar(value='activities' in fwd)
    fwd_evt = tk.BooleanVar(value='events' in fwd)

    _etnf_field_row(frame, 0, "Webhook URL:", url_var, width=52)

    tk.Label(frame, text="Forward:").grid(row=1, column=0, sticky='w', padx=10, pady=3)
    fwd_f = tk.Frame(frame)
    fwd_f.grid(row=1, column=1, sticky='w')
    tk.Checkbutton(fwd_f, text="Activity snapshots (~60 s)", variable=fwd_act).pack(side='left')
    tk.Checkbutton(fwd_f, text="Journal events (every 5 s — verbose)", variable=fwd_evt).pack(
        side='left', padx=12)

    tk.Label(frame,
             text="How to get a webhook URL: Discord channel -> Edit -> Integrations -> Webhooks -> New Webhook -> Copy URL.",
             foreground='#555', font=('TkDefaultFont', 8), wraplength=420, justify='left').grid(
        row=2, column=0, columnspan=3, sticky='w', padx=10, pady=(2, 4))

    def collect() -> dict:
        url = url_var.get().strip()
        if not url:
            raise ValueError("Discord webhook URL is required.")
        if 'discord' not in url and not messagebox.askyesno(
                "ETNF", "URL doesn't look like a Discord webhook — continue anyway?"):
            raise ValueError("Cancelled.")
        forward = [ep for ep, on in [('activities', fwd_act), ('events', fwd_evt)] if on.get()]
        if not forward:
            raise ValueError("Enable at least one type to forward.")
        return {'url': url, 'forward': forward}

    return collect


def _etnf_field_row(frame: tk.Frame, row: int, label: str, var: tk.StringVar,
                    width: int = 40, secret: bool = False) -> None:
    tk.Label(frame, text=label).grid(row=row, column=0, sticky='w', padx=10, pady=3)
    show = '*' if secret else ''
    ent  = tk.Entry(frame, textvariable=var, width=width, show=show)
    ent.grid(row=row, column=1, sticky='w', padx=6, pady=3)
    if secret:
        rv = tk.BooleanVar(value=False)
        def toggle(e=ent, r=rv):
            e.config(show='' if r.get() else '*')
        tk.Checkbutton(frame, text="Show", variable=rv, command=toggle).grid(
            row=row, column=2, padx=4)
