import enum
import json
import logging
from logging.handlers import RotatingFileHandler
from datetime import datetime, timezone
from urllib.parse import parse_qs
import os

from flask import Flask, request, jsonify, make_response
from dataclasses import dataclass, asdict
from threading import Lock
from flask import render_template, redirect, url_for
import atexit

import importlib

# Do not import BleComm functions at module-import time (it can create a different
# module instance than the one started by ensure_ble_started). Instead, define a
# small delegating wrapper that ensures the lazily-loaded BleComm instance is used.


def send_led_command(data0: int, data1: int, data2: int, cmd: int) -> bool:
    """Delegate BLE writes to the BleComm module started by ensure_ble_started().

    This wrapper makes sure we call the same module instance (BleComm) that
    ensure_ble_started() imported and started, avoiding duplicate-module state.
    """
    try:
        ensure_ble_started()
        global BleComm
        if BleComm is None:
            app.logger.warning("BleComm module not available")
            return False
        # delegate
        return BleComm.send_led_command(data0, data1, data2, cmd)
    except Exception as e:
        app.logger.error(f"Exception sending BLE command: {e}")
        return False

CMD_SET_ANIMATION = 0x2C
CMD_SET_STATIC_COLOR = 0x1E

# BleComm will be imported lazily inside ensure_ble_started() to avoid circular imports
BleComm = None

# ensure we only start BleComm once
_ble_started = False

def ensure_ble_started():
    global _ble_started
    if _ble_started:
        return
    try:
        global BleComm
        if BleComm is None:
            # Preferred: import as package
            try:
                BleComm = importlib.import_module('src.BleComm')
            except Exception as e1:
                # Some run modes execute src/app.py as a script and the 'src' package
                # may not be on sys.path; try adding the repo root and retrying.
                logging.getLogger('BleComm').warning(f"import src.BleComm failed: {e1}; attempting fallback imports")
                try:
                    import sys
                    repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
                    if repo_root not in sys.path:
                        sys.path.insert(0, repo_root)
                    BleComm = importlib.import_module('src.BleComm')
                except Exception as e2:
                    logging.getLogger('BleComm').warning(f"fallback import via sys.path failed: {e2}; trying SourceFileLoader")
                    try:
                        # Last resort: load module directly from file path
                        from importlib.machinery import SourceFileLoader
                        loader = SourceFileLoader('src.BleComm', os.path.join(os.path.dirname(__file__), 'BleComm.py'))
                        spec = importlib.util.spec_from_loader(loader.name, loader)
                        module = importlib.util.module_from_spec(spec)
                        loader.exec_module(module)
                        BleComm = module
                    except Exception as e3:
                        logging.getLogger('BleComm').error(f"All attempts to import BleComm failed: {e1} | {e2} | {e3}")
                        return
        if BleComm is not None:
            BleComm.start()
            atexit.register(lambda: BleComm.stop())
            _ble_started = True
            # log via app logger if available
            try:
                app.logger.info("BleComm started")
            except Exception:
                logging.getLogger('BleComm').info("BleComm started")
    except Exception as e:
        # avoid referencing app here in case it's not defined yet
        logging.getLogger('BleComm').error(f"Failed to start BleComm: {e}")
    
 

# ---------------- Config ----------------
HOST = "0.0.0.0"          # or "127.0.0.1" if only local machine will hit it
PORT = 8080
LOG_FILE = "dslrbooth_triggers.log"
JSONL_FILE = "dslrbooth_triggers.jsonl"
MAX_LOG_BYTES = 5 * 1024 * 1024  # 5MB
BACKUP_COUNT = 3
# Optional: set an env var SECRET_TOKEN to require X-Auth-Token header
SECRET_TOKEN = os.getenv("SECRET_TOKEN", "").strip()
# ---------------------------------------

class LED_SHOW(enum.Enum):
    ANIMATION = 0
    STATIC_COLOR = 1

@dataclass
class AnimationConfig:
    home_state: LED_SHOW = LED_SHOW.ANIMATION
    home_color: str = "#000000"
    home_anim: int = 0
    countdown_state: LED_SHOW = LED_SHOW.ANIMATION
    countdown_color: str = "#FFFFFF"
    countdown_anim: int = 0
    sharing_state: LED_SHOW = LED_SHOW.ANIMATION
    sharing_color: str = "#0000FF"
    sharing_anim: int = 0
    
    def to_dict(self) -> dict:
        return asdict(self)
    
class BLEConfig:
    auto_connect: bool = False
    address: str = ""
    device_name: str = ""
    auto_connect: bool = True  
    filter_name: str = "SP110E-ATA"
    service_uuid: str = "0xFFE0"  
    characteristic_uuid: str = "0xFFE1"
    def to_dict(self) -> dict:
        return {
            "device_name": self.device_name,
            "auto_connect": self.auto_connect,
            "filter_name": self.filter_name
        }

def parse_state(val) -> LED_SHOW:
    """Normalize a stored state value (bool, str, int) to LED_SHOW enum."""
    try:
        # booleans: True -> ANIMATION, False -> STATIC_COLOR
        if isinstance(val, bool):
            return LED_SHOW.ANIMATION if val else LED_SHOW.STATIC_COLOR
        # numbers: 0 -> ANIMATION? legacy uses 0/1 ambiguous; treat 0 as ANIMATION to match defaults
        if isinstance(val, int):
            return LED_SHOW.ANIMATION if val == 0 else LED_SHOW.STATIC_COLOR
        if isinstance(val, str):
            s = val.strip().lower()
            if s in ("animation", "anim", "a"):
                return LED_SHOW.ANIMATION
            if s in ("solid", "static", "color", "c", "s"):
                return LED_SHOW.STATIC_COLOR
            # allow 'true'/'false' strings
            if s in ("true", "1"):
                return LED_SHOW.ANIMATION
            if s in ("false", "0"):
                return LED_SHOW.STATIC_COLOR
        # fallback: default to ANIMATION
    except Exception:
        pass
    return LED_SHOW.ANIMATION


# Global, thread-safe animation config instance that handlers can read/update
LEDAnimations = AnimationConfig()
LEDAnimations_lock = Lock()
# track last event timestamp (epoch seconds) for a simple 'comm' status
last_event_ts = None
  

# Ensure Flask looks for templates in the repository-level `templates/` folder
TEMPLATES_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "templates"))
STATIC_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "static"))
# Ensure Flask will serve files from the repository-level 'static' folder
app = Flask(__name__, template_folder=TEMPLATES_DIR, static_folder=STATIC_DIR)


# Single-instance helper: acquire an OS-level lock file in the data directory so
# only one instance of the app can run. Keeps the filehandle open for process
# lifetime. Uses msvcrt on Windows and fcntl on POSIX.
_app_lock_fh = None

def acquire_instance_lock(lock_path: str):
    """Try to acquire exclusive lock on lock_path. Returns file handle or None."""
    global _app_lock_fh
    try:
        os.makedirs(os.path.dirname(lock_path), exist_ok=True)
        fh = open(lock_path, 'a+')
    except Exception:
        return None
    try:
        if os.name == 'nt':
            try:
                import msvcrt
                # Lock the first byte (non-blocking)
                msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
            except Exception:
                try:
                    fh.close()
                except Exception:
                    pass
                return None
        else:
            try:
                import fcntl
                fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
            except Exception:
                try:
                    fh.close()
                except Exception:
                    pass
                return None
    except Exception:
        try:
            fh.close()
        except Exception:
            pass
        return None
    # keep filehandle alive for lifetime of process
    _app_lock_fh = fh
    return fh

def release_instance_lock():
    global _app_lock_fh
    try:
        if _app_lock_fh is None:
            return
        try:
            if os.name == 'nt':
                import msvcrt
                try:
                    _app_lock_fh.seek(0)
                    msvcrt.locking(_app_lock_fh.fileno(), msvcrt.LK_UNLCK, 1)
                except Exception:
                    pass
            else:
                import fcntl
                try:
                    fcntl.flock(_app_lock_fh.fileno(), fcntl.LOCK_UN)
                except Exception:
                    pass
        except Exception:
            pass
        try:
            _app_lock_fh.close()
        except Exception:
            pass
    finally:
        _app_lock_fh = None


@app.before_request
def _ensure_ble_on_request():
    # Ensure BleComm is started before handling any request. Idempotent.
    ensure_ble_started()

# Logging: rotating file handler + colored console handler
# Clear any existing handlers to avoid duplicate logs on reload
app.logger.handlers.clear()

# Rotating file handler (plain text)
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=MAX_LOG_BYTES, backupCount=BACKUP_COUNT, encoding="utf-8")
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter("%(asctime)s.%(msecs)03d %(levelname)s:%(name)s:%(message)s", datefmt="%Y-%m-%dT%H:%M:%S"))

# Console handler (no colorlog)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(logging.Formatter("%(asctime)s.%(msecs)03d %(levelname)s:%(name)s:%(message)s", datefmt="%H:%M:%S"))

app.logger.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
app.logger.addHandler(console_handler)
app.logger.propagate = False

# Silence default werkzeug HTTP access logs (they clutter stdout)
logging.getLogger('werkzeug').setLevel(logging.WARNING)

def now_stamp():
    # HH.MM.SS.mmm (period separators, millisecond precision)
    # use full microsecond then trim to milliseconds
    return datetime.now().strftime("%H.%M.%S.%f")[:-3]


# Load animation settings from data/config.json into LEDAnimations
def load_animation_config_from_file():
    try:
        cfg_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'data', 'config.json'))
        if not os.path.exists(cfg_path):
            app.logger.info(f"Animation config file not found: {cfg_path}")
            return
        with open(cfg_path, 'r', encoding='utf-8') as f:
            data = json.load(f)
        anim = data.get('animation-events', {})
        with LEDAnimations_lock:
            # home
            if 'home-state' in anim:
                LEDAnimations.home_state = parse_state(anim.get('home-state'))
            if 'home-color' in anim and anim.get('home-color'):
                LEDAnimations.home_color = str(anim.get('home-color'))
            if 'home-anim' in anim:
                try:
                    LEDAnimations.home_anim = int(anim.get('home-anim'))
                except Exception:
                    pass

            # countdown
            if 'countdown-state' in anim:
                LEDAnimations.countdown_state = parse_state(anim.get('countdown-state'))
            if 'countdown-color' in anim and anim.get('countdown-color'):
                LEDAnimations.countdown_color = str(anim.get('countdown-color'))
            if 'countdown-anim' in anim:
                try:
                    LEDAnimations.countdown_anim = int(anim.get('countdown-anim'))
                except Exception:
                    pass

            # sharing
            if 'sharing-state' in anim:
                LEDAnimations.sharing_state = parse_state(anim.get('sharing-state'))
            if 'sharing-color' in anim and anim.get('sharing-color'):
                LEDAnimations.sharing_color = str(anim.get('sharing-color'))
            if 'sharing-anim' in anim:
                try:
                    LEDAnimations.sharing_anim = int(anim.get('sharing-anim'))
                except Exception:
                    pass
        app.logger.info(f"Loaded animation config from {cfg_path}")
    except Exception as e:
        app.logger.error(f"Failed to load animation config: {e}")


# Load BLE/communication defaults from data/config.json into a BLEConfig
CONFIG_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'data', 'config.json'))
COMM_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'data', 'comm.json'))

def load_ble_config() -> BLEConfig:
    cfg = BLEConfig()
    try:
        # BLE/communication defaults are stored in comm.json
        if not os.path.exists(COMM_PATH):
            # fallback to legacy config.json
            app.logger.debug(f"{COMM_PATH} not found; falling back to config.json for BLE settings")
            if os.path.exists(CONFIG_PATH):
                try:
                    with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
                        data = json.load(f) or {}
                    ble = data.get('ble', {})
                except Exception:
                    ble = {}
            else:
                return cfg
        with open(COMM_PATH, 'r', encoding='utf-8') as f:
            data = json.load(f)
        ble = data.get('ble', {})
        # if comm.json exists but has no ble section, try legacy config.json
        if not ble:
            if os.path.exists(CONFIG_PATH):
                try:
                    with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
                        legacy = json.load(f) or {}
                    legacy_ble = legacy.get('ble', {})
                    if legacy_ble:
                        app.logger.debug('comm.json has no ble section; using ble from config.json')
                        ble = legacy_ble
                except Exception:
                    pass
        # support both snake_case and kebab-case keys
        cfg.device_name = ble.get('device_name', '') or ble.get('device-name', '') or cfg.device_name
        cfg.address = ble.get('address', '') or ble.get('addr', '') or cfg.address
        cfg.auto_connect = bool(ble.get('auto_connect', False) or ble.get('auto-connect', False) or cfg.auto_connect)
        cfg.filter_name = ble.get('filter_name', '') or ble.get('filter-name', '') or cfg.filter_name
        cfg.service_uuid = ble.get('service_uuid', '') or ble.get('service-uuid', '') or cfg.service_uuid
        cfg.characteristic_uuid = ble.get('characteristic_uuid', '') or ble.get('characteristic-uuid', '') or cfg.characteristic_uuid
    except Exception as e:
        app.logger.debug(f"load_ble_config failed: {e}")
    return cfg


def load_comm_config() -> dict:
    """Load comm.json and return a dict with keys like host, port, and ble."""
    try:
        if not os.path.exists(COMM_PATH):
            return {}
        with open(COMM_PATH, 'r', encoding='utf-8') as f:
            return json.load(f) or {}
    except Exception as e:
        app.logger.debug(f"load_comm_config failed: {e}")
        return {}


def persist_animation_events(ae: dict) -> bool:
    """Persist only the 'animation-events' section into CONFIG_PATH, preserving other keys."""
    try:
        base = {}
        if os.path.exists(CONFIG_PATH):
            try:
                with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
                    base = json.load(f) or {}
            except Exception:
                base = {}
        base['animation-events'] = ae
        with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
            json.dump(base, f, indent=2)
        return True
    except Exception as e:
        app.logger.debug(f"persist_animation_events failed: {e}")
        return False


def persist_comm_config(comm: dict) -> bool:
    """Write the provided comm dict to COMM_PATH atomically."""
    try:
        tmp = COMM_PATH + '.tmp'
        with open(tmp, 'w', encoding='utf-8') as f:
            json.dump(comm, f, indent=2)
        os.replace(tmp, COMM_PATH)
        return True
    except Exception as e:
        app.logger.debug(f"persist_comm_config failed: {e}")
        return False


def tail_log(lines: int = 200) -> str:
    """Return the last `lines` lines from the main log file as a single string."""
    try:
        if not os.path.exists(LOG_FILE):
            return ''
        # read in a memory-efficient way: read tail by seeking from end
        with open(LOG_FILE, 'rb') as f:
            f.seek(0, os.SEEK_END)
            end = f.tell()
            block_size = 1024
            data = bytearray()
            while end > 0 and len(data) < lines * 200:
                read_size = min(block_size, end)
                f.seek(end - read_size, os.SEEK_SET)
                chunk = f.read(read_size)
                data[0:0] = chunk
                end -= read_size
            text = data.decode('utf-8', errors='replace')
            all_lines = text.splitlines()
            return '\n'.join(all_lines[-lines:])
    except Exception as e:
        app.logger.debug(f"tail_log failed: {e}")
        return ''

# Load animation config at module import so LEDAnimations is initialized from file
try:
    # In some run modes app isn't fully configured yet; guard with try/except
    load_animation_config_from_file()
except Exception:
    pass

# Normalize incoming payload to at least include event_type and any param* fields.
# Returns a dict with at least 'event_type' key if found, plus any param*
def normalize_event(payload: dict) -> dict:
    """Normalize incoming payload to at least include event_type and any param* fields."""
    event = {}

    # If raw strings like {" event_type: 'session_start', param1: '5' "} come in,
    # they might not be strict JSON. Try to coerce if needed:
    # But first, trust anything the client already parsed as JSON.

    # Ensure keys are strings and trim spaces
    clean = {}
    for k, v in payload.items():
        if isinstance(k, str):
            key = k.strip()
        else:
            key = str(k)
        # flatten single-item lists (e.g., parse_qs output)
        if isinstance(v, list) and len(v) == 1:
            v = v[0]
        clean[key] = v

    # event_type required
    if "event_type" in clean:
        event["event_type"] = str(clean["event_type"])
    elif "EventType" in clean:
        event["event_type"] = str(clean["EventType"])
    else:
        # try some common fallbacks
        for alt in ("event", "type"):
            if alt in clean:
                event["event_type"] = str(clean[alt])
                break

    # Pull param1..param9 (typical) but also keep any extra fields users might send
    for k, v in clean.items():
        if k == "event_type":
            continue
        if k.lower().startswith("param") or k in ("session_id", "file", "path", "url", "share_url", "album", "device", "printer", "count"):
            event[k] = v

    # If nothing else, keep the rest (but avoid duplicating known keys)
    for k, v in clean.items():
        if k not in event and k != "event_type":
            event[k] = v

    return event

# Flexible body parsing
# Accept JSON, form-encoded, querystring, or raw 'a=b&c=d'.
# Returns dict (may be empty).
def parse_body():
    """
    Accept JSON, form-encoded, querystring, or raw 'a=b&c=d'.
    Returns dict (may be empty).
    """
    # 1) JSON
    if request.is_json:
        try:
            data = request.get_json(silent=True) or {}
            if isinstance(data, dict):
                return data
        except Exception:
            pass

    # 2) Form fields
    if request.form:
        return {k: v for k, v in request.form.items()}

    # 3) Query string
    if request.args:
        return {k: v for k, v in request.args.items()}

    # 4) Raw body like "event_type=session_start&param1=PrintAndGIF"
    try:
        raw = request.get_data(as_text=True) or ""
        if raw:
            qs = parse_qs(raw, keep_blank_values=True)
            return {k: (v[0] if isinstance(v, list) and len(v) == 1 else v) for k, v in qs.items()}
    except Exception:
        pass

    return {}

# Log a compact JSON line with timestamp prefix
def log_event_line(event: dict):
    # Compact JSON (no spaces) for the log line
    compact = json.dumps(event, separators=(",", ":"))
    line = f"{now_stamp()} {compact}"
    try:
        app.logger.debug(line)
    except Exception:
        pass
    return line

# Optional: brief three-line log for easier reading
def log_request_brief():
    """Log a brief three-line message similar to the example:
    GET /?event_type=session_end
    2025-09-26T22:27:59-07:00
    76.86.154.250
    """
    try:
        # Request line
        qs = "" if not request.query_string else f"?{request.query_string.decode('utf-8')}"
        request_line = f"{request.method} {request.path}{qs}"
        app.logger.info(request_line)

        # ISO timestamp with local timezone offset, without microseconds
        ts = datetime.now().astimezone().replace(microsecond=0).isoformat()
        app.logger.info(ts)

        # Client IP (Flask provides remote_addr, may be None behind proxies)
        client = request.remote_addr or request.headers.get("X-Forwarded-For", "unknown")
        app.logger.info(client)
    except Exception as e:
        app.logger.info(f"Failed to write brief request log: {e}")

def build_event_from_query(payload: dict) -> dict:
    """Build a normalized event structure containing event_type and params array.

    payload is expected to be a dict of query/form keys -> values.
    Returns: { 'event_type': str, 'params': [ (k, v), ... ] }
    """
    event_type = None
    params = []
    # prefer explicit event_type key
    if "event_type" in payload:
        event_type = str(payload.get("event_type"))

    # collect param1..paramN in sorted numeric order if possible, else in insertion order
    # also include other keys except event_type
    # make a stable ordering: param1,param2,... then remaining keys alphabetically
    param_items = []
    other_items = []
    for k, v in payload.items():
        if k == "event_type":
            continue
        if k.lower().startswith("param"):
            param_items.append((k, v))
        else:
            other_items.append((k, v))

    # sort param_items by number if includes digits (param1, param2...)
    def param_sort_key(item):
        k = item[0]
        digits = ''.join(ch for ch in k if ch.isdigit())
        return (int(digits) if digits else 9999, k)

    param_items.sort(key=param_sort_key)
    other_items.sort(key=lambda x: x[0])

    params = param_items + other_items
    return {"event_type": event_type, "params": params}

def log_event_single_line(event_struct: dict):
    """Format and log the event in a single line:
    Event: <event_type>, param1: <data>, param2: <data>, ...
    """
    et = event_struct.get("event_type") or ""
    parts = [f"Event: {et}"]
    for k, v in event_struct.get("params", []):
        parts.append(f"{k}: {v}")
    try:
        app.logger.debug(", ".join(parts))
    except Exception:
        pass

def append_jsonl(event: dict):
    with open(JSONL_FILE, "a", encoding="utf-8") as f:
        f.write(json.dumps(event, ensure_ascii=False) + "\n")


def handle_event_dispatch(event: dict, payload: dict):
    """Dispatch handling based on event_type. Uses Python 3.10 match/case.

    event: normalized event dict (must contain 'event_type')
    payload: original parsed payload dict (for extra context if needed)
    """
    et = event.get("event_type", "")
    try:
        match et:
            case "capture_start":
                # when camera capture starts
                # Switch animation to constant white for both lights
                #app.logger.info("Start White Countdown Animation")
                app.logger.info("capture_start event received")
            case "countdown":
                # do nothing here
                pass
            case "countdown_start" :
                countDownSecs_raw = payload.get("param1", event.get("param1", ""))
                # Normalize to an integer when possible
                try:
                    countDownSecs = int(countDownSecs_raw)
                except (TypeError, ValueError):
                    countDownSecs = None

                if isinstance(countDownSecs, int):
                    if countDownSecs > 5:
                        countDownSecs = 5

                    result = send_led_command(countDownSecs, 0, 0, CMD_SET_ANIMATION)
                    
                    if not result:
                        app.logger.warning("Failed to send BLE command for countdown animation")
                    app.logger.info(f"Countdown started - {countDownSecs} sec(s)")
                        
                else:
                    app.logger.info(f"Countdown started - unknown duration (param: {countDownSecs_raw})")
            case "sharing_screen" | "sharing":
                if(LEDAnimations.sharing_state == LED_SHOW.ANIMATION):
                    result = send_led_command(LEDAnimations.sharing_anim, 0, 0, CMD_SET_ANIMATION)
                else:
                    r = int(LEDAnimations.sharing_color[1:3], 16)
                    g = int(LEDAnimations.sharing_color[3:5], 16)
                    b = int(LEDAnimations.sharing_color[5:7], 16)
                    result = send_led_command(r, g, b, CMD_SET_STATIC_COLOR)
                
                
                if not result:
                    app.logger.warning("Failed to send BLE command for session end animation")
                app.logger.info("Sharing screen Animation")
            case "session_start" | "start_session":
                param1 = payload.get("param1", event.get("param1", ""))
                if param1 == "PrintOnly":
                    app.logger.info("Session started - PrintOnly")
                elif param1 == "PrintAndGIF":
                    app.logger.info("Session started - PrintAndGIF")
                elif param1 == "OnlyGIF":
                    app.logger.info("Session started - OnlyGIF")
                elif param1 == "Boomerang":
                    app.logger.info("Session started - Boomerang")
                elif param1 == "Video":
                    app.logger.info("Session started - Video")
                else:
                    app.logger.info(f"Session started - unknown mode: '{param1}'")
            case "session_end" | "end_session":  
                if(LEDAnimations.home_state == LED_SHOW.ANIMATION):
                    result = send_led_command(LEDAnimations.home_anim, 0, 0, CMD_SET_ANIMATION)
                else:
                    r = int(LEDAnimations.home_color[1:3], 16)
                    g = int(LEDAnimations.home_color[3:5], 16)
                    b = int(LEDAnimations.home_color[5:7], 16)
                    result = send_led_command(r, g, b, CMD_SET_STATIC_COLOR)

                if not result:
                    app.logger.warning("Failed to send BLE command for session end animation")
            case "file_download":
                filename = payload.get("param1", event.get("param1", ""))
                if filename:
                    app.logger.info(f"File downloaded: {filename}")
                else:
                    app.logger.info("File download event with no filename")
            case "file_upload":
                filename = payload.get("param1", event.get("param1", ""))
                if filename:
                    app.logger.info(f"File uploaded: {filename}")
                else:
                    app.logger.info("File upload event with no filename")
            case "processing_start":
                app.logger.info("Processing started...")
            case _:
                # Default: unknown event_type
                app.logger.warning(f"Handler: unhandled event_type '{et}'")
    except Exception as e:
        app.logger.error(f"Exception in handle_event_dispatch: {e}")

def check_token():
    if not SECRET_TOKEN:
        return True
    return request.headers.get("X-Auth-Token", "") == SECRET_TOKEN

@app.route("/health", methods=["GET"])
def health():
    return jsonify({"status": "ok", "port": PORT})

@app.route("/api", methods=["GET", "POST"])
def root():
    """Handle requests to the API root /api (moved from /)"""
    # Informational root: does not accept event payloads. Use /dslrbooth for events.
    return jsonify({"status": "api root", "note": "send event_type payloads to /dslrbooth"})

@app.route("/dslrbooth", methods=["POST", "GET"])
def dslrbooth():
    # Build event from payload and log single-line summary
    payload = parse_body()
    # build and log single-line summary
    log_event_single_line(build_event_from_query(payload))

    if not check_token():
        return make_response(jsonify({"error": "unauthorized"}), 401)

    event = normalize_event(payload)
    if "event_type" not in event:
        return make_response(jsonify({"error": "event_type missing"}), 400)

    # Dispatch to per-event_type handlers (non-blocking, side-effect logging)
    handle_event_dispatch(event, payload)

    line = log_event_line(event)
    try:
        append_jsonl({"ts": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z'), **event})
    except Exception as e:
        app.logger.error(f"Failed to append JSONL: {e}")

    # update last_event_ts for comm status
    try:
        global last_event_ts
        last_event_ts = datetime.now(timezone.utc).timestamp()
    except Exception:
        pass

    return jsonify({"ok": True, "logged": line})

@app.route('/', methods=['GET'])
def config_index():
    # Render index.html with current animation config
    with LEDAnimations_lock:
        cfg = LEDAnimations.to_dict()
    # derive simple boolean statuses for template (green if non-zero)
    def is_enabled(val):
        try:
            return int(val) != 0
        except Exception:
            return bool(val)

    # comm_status: consider 'up' if we received an event within the last 60 seconds
    comm_up = False
    try:
        if last_event_ts is not None:
            age = datetime.now(timezone.utc).timestamp() - float(last_event_ts)
            comm_up = age <= 60
    except Exception:
        comm_up = False

    return render_template('index.html', config=cfg,
                           comm_status=comm_up,
                           capture_start_animation_status=is_enabled(cfg.get('capture_start_animation')),
                           countdown_animation_status=is_enabled(cfg.get('countdown_animation')),
                           sharing_screen_animation_status=is_enabled(cfg.get('sharing_screen_animation')),
                           session_start_animation_status=is_enabled(cfg.get('session_start_animation')),
                           session_end_animation_status=is_enabled(cfg.get('session_end_animation')))


@app.route('/config-animation', methods=['GET'])
def get_config_animation():
    # Return current animation configuration (used by front-end JS)
    with LEDAnimations_lock:
        reply = {
            "home_mode": "animation" if LEDAnimations.home_state == LED_SHOW.ANIMATION else "solid",
            "home_anim": LEDAnimations.home_anim,
            "home_color": LEDAnimations.home_color,
            "countdown_mode": "animation" if LEDAnimations.countdown_state == LED_SHOW.ANIMATION else "solid",
            "countdown_anim": LEDAnimations.countdown_anim,
            "countdown_color": LEDAnimations.countdown_color,
            "sharing_mode": "animation" if LEDAnimations.sharing_state == LED_SHOW.ANIMATION else "solid",
            "sharing_anim": LEDAnimations.sharing_anim,
            "sharing_color": LEDAnimations.sharing_color,
        }
    return jsonify(reply)

@app.route('/config-animation', methods=['POST'])
def set_config_animation():
    # Update animation configuration.
    # Accept JSON or form-encoded bodies; update LEDAnimations in-memory and
    # persist only the 'animation-events' section in the config file, leaving
    # other top-level keys intact.
    payload = parse_body()
    reply = {"status": "success"}
    app.logger.info(f"Config update payload: {payload}")
    try:
        with LEDAnimations_lock:
            # home/home screen
            if 'home_mode' in payload:
                LEDAnimations.home_state = LED_SHOW.ANIMATION if str(payload.get('home_mode')) == 'animation' else LED_SHOW.STATIC_COLOR
            if 'home_color' in payload and payload.get('home_color'):
                LEDAnimations.home_color = str(payload.get('home_color'))
            if 'home_animation' in payload and payload.get('home_animation') != '':
                try:
                    LEDAnimations.home_anim = int(payload.get('home_nimation'))
                except Exception:
                    pass

            # countdown
            if 'countdown_mode' in payload:
                LEDAnimations.countdown_state = LED_SHOW.ANIMATION if str(payload.get('countdown_mode')) == 'animation' else LED_SHOW.STATIC_COLOR
            if 'countdown_color' in payload and payload.get('countdown_color'):
                LEDAnimations.countdown_color = str(payload.get('countdown_color'))
            if 'countdown_animation' in payload and payload.get('countdown_animation') != '':
                try:
                    LEDAnimations.countdown_anim = int(payload.get('countdown_animation'))
                except Exception:
                    pass

            # sharing
            if 'sharing_mode' in payload:
                LEDAnimations.sharing_state = LED_SHOW.ANIMATION if str(payload.get('sharing_mode')) == 'animation' else LED_SHOW.STATIC_COLOR
            if 'sharing_color' in payload and payload.get('sharing_color'):
                LEDAnimations.sharing_color = str(payload.get('sharing_color'))
            if 'sharing_animation' in payload and payload.get('sharing_animation') != '':
                try:
                    LEDAnimations.sharing_anim = int(payload.get('sharing_animation'))
                except Exception:
                    pass

        # Persist only the animation-events section in the config file.
        try:
            base = {}
            if os.path.exists(CONFIG_PATH):
                try:
                    with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
                        base = json.load(f) or {}
                except Exception:
                    base = {}

            ae = base.get('animation-events', {})
            ae['home-state'] = 'animation' if LEDAnimations.home_state == LED_SHOW.ANIMATION else 'solid'
            ae['home-color'] = LEDAnimations.home_color
            ae['home-anim'] = int(LEDAnimations.home_anim)

            ae['countdown-state'] = 'animation' if LEDAnimations.countdown_state == LED_SHOW.ANIMATION else 'solid'
            ae['countdown-color'] = LEDAnimations.countdown_color
            ae['countdown-anim'] = int(LEDAnimations.countdown_anim)

            ae['sharing-state'] = 'animation' if LEDAnimations.sharing_state == LED_SHOW.ANIMATION else 'solid'
            ae['sharing-color'] = LEDAnimations.sharing_color
            ae['sharing-anim'] = int(LEDAnimations.sharing_anim)

            base['animation-events'] = ae
            with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
                json.dump(base, f, indent=2)
        except Exception as e:
            app.logger.debug(f"Failed to persist animation config: {e}")
    except Exception as e:
        app.logger.error(f"Failed to update animation config: {e}")
        reply["status"] = "error"
    return jsonify(reply)

@app.route('/comm-status', methods=['GET'])
def get_comm_status():
    # Return current communication status for front-end
    # Defaults from data/config.json
    cfg = load_ble_config()
    device_name = cfg.device_name
    address = cfg.address
    auto_connect = cfg.auto_connect
    filter_name = cfg.filter_name
    try:
        # load_ble_config already handled reading file; values available in cfg
        pass
    except Exception as e:
        app.logger.debug(f"Failed to read comm defaults: {e}")

    connected = False
    try:
        if last_event_ts is not None:
            age = datetime.now(timezone.utc).timestamp() - float(last_event_ts)
            connected = age <= 60
    except Exception:
        connected = False

    reply = {
        "id": address or "",
        "name": device_name,
        "filter": filter_name,
        "filter_name": filter_name,
        "address": address or "",
        "auto": auto_connect,
        "connected": connected,
        "last_connected_ts": datetime.now(timezone.utc).isoformat() if connected else None
    }
    return jsonify(reply)

@app.route('/comm-status', methods=['POST'])
def set_comm_status():
    # Return current communication status
    reply = {"status": "disconnected", "id":" ", "device-name":" ", "filter-name":" ", "auto-connect":True}
    return jsonify(reply)

@app.route('/update', methods=['POST'])
def config_update():
    # Accept form-encoded submission from the UI. For diagnostics, log the payload.
    form = request.form
    try:
        payload = {k: v for k, v in form.items()}
        app.logger.info(f"/update payload: {payload}")

        with LEDAnimations_lock:
            # map form fields to LEDAnimations similarly to /config-animation
            if 'home_mode' in payload:
                LEDAnimations.home_state = LED_SHOW.ANIMATION if str(payload.get('home_mode')) == 'animation' else LED_SHOW.STATIC_COLOR
            if 'home_color' in payload and payload.get('home_color'):
                LEDAnimations.home_color = str(payload.get('home_color'))
            if 'capture_start_animation' in payload and payload.get('capture_start_animation') != '':
                try:
                    LEDAnimations.home_anim = int(payload.get('capture_start_animation'))
                except Exception:
                    pass

            if 'countdown_mode' in payload:
                LEDAnimations.countdown_state = LED_SHOW.ANIMATION if str(payload.get('countdown_mode')) == 'animation' else LED_SHOW.STATIC_COLOR
            if 'countdown_color' in payload and payload.get('countdown_color'):
                LEDAnimations.countdown_color = str(payload.get('countdown_color'))
            if 'countdown_animation' in payload and payload.get('countdown_animation') != '':
                try:
                    LEDAnimations.countdown_anim = int(payload.get('countdown_animation'))
                except Exception:
                    pass

            if 'sharing_mode' in payload:
                LEDAnimations.sharing_state = LED_SHOW.ANIMATION if str(payload.get('sharing_mode')) == 'animation' else LED_SHOW.STATIC_COLOR
            if 'sharing_color' in payload and payload.get('sharing_color'):
                LEDAnimations.sharing_color = str(payload.get('sharing_color'))
            if 'sharing_screen_animation' in payload and payload.get('sharing_screen_animation') != '':
                try:
                    LEDAnimations.sharing_anim = int(payload.get('sharing_screen_animation'))
                except Exception:
                    pass

            # Also support legacy numeric-only fields that were originally handled here
            for field in ('capture_start_animation','countdown_animation','sharing_screen_animation','session_start_animation','session_end_animation'):
                if field in payload and payload.get(field) != '':
                    try:
                        val = int(payload.get(field))
                        # try to set directly if attribute exists, else ignore
                        if hasattr(LEDAnimations, field):
                            setattr(LEDAnimations, field, val)
                    except Exception:
                        # ignore bad conversion
                        pass

        # Persist only the animation-events section in the config file (preserve other keys)
        try:
            base = {}
            if os.path.exists(CONFIG_PATH):
                try:
                    with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
                        base = json.load(f) or {}
                except Exception:
                    base = {}

            ae = base.get('animation-events', {})
            ae['home-state'] = 'animation' if LEDAnimations.home_state == LED_SHOW.ANIMATION else 'solid'
            ae['home-color'] = LEDAnimations.home_color
            ae['home-anim'] = int(LEDAnimations.home_anim)

            ae['countdown-state'] = 'animation' if LEDAnimations.countdown_state == LED_SHOW.ANIMATION else 'solid'
            ae['countdown-color'] = LEDAnimations.countdown_color
            ae['countdown-anim'] = int(LEDAnimations.countdown_anim)

            ae['sharing-state'] = 'animation' if LEDAnimations.sharing_state == LED_SHOW.ANIMATION else 'solid'
            ae['sharing-color'] = LEDAnimations.sharing_color
            ae['sharing-anim'] = int(LEDAnimations.sharing_anim)

            base['animation-events'] = ae
            with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
                json.dump(base, f, indent=2)
        except Exception as e:
            app.logger.debug(f"Failed to persist animation config from /update: {e}")

        return redirect(url_for('config_index'))
    except Exception as e:
        app.logger.info(f"Config update failed: {e}")
        return make_response(jsonify({"error": "failed to update"}), 500)


# The executable startup block is intentionally placed at the end of the file
# after all routes are defined so that imports and route registration occur
# before app.run() is invoked when this module is executed directly.



@app.route('/ble/status', methods=['GET'])
def ble_status():
    """Return BLE connection status (simple JSON)."""
    try:
        # import lazily and query module helper if present
        mod = importlib.import_module('src.BleComm')
        if hasattr(mod, 'get_status'):
            return jsonify(mod.get_status())
        # fallback: inspect internals
        client = getattr(mod, '_client', None)
        addr = getattr(mod, '_address', None)
        connected = bool(getattr(client, 'is_connected', False)) if client is not None else False
        return jsonify({"connected": connected, "address": addr})
    except Exception as e:
        app.logger.error(f"ble/status failed: {e}")
        return jsonify({"connected": False, "error": str(e)})


@app.route('/ble/scan', methods=['GET'])
def ble_scan():
    """Scan for BLE devices. Optional query param: prefix to filter by name.

    Returns JSON array: [{"name": ..., "address": ..., "id": ...}, ...]
    """
    prefix = request.args.get('prefix', '') or ''
    try:
        ensure_ble_started()
        mod = importlib.import_module('src.BleComm')
        devices = []
        # Prefer a scan() function exposed by the module
        if hasattr(mod, 'scan') and callable(getattr(mod, 'scan')):
            try:
                devices = mod.scan(prefix)
            except TypeError:
                # maybe scan() expects no args
                devices = mod.scan()
        elif hasattr(mod, 'discover') and callable(getattr(mod, 'discover')):
            # fallback name
            try:
                devices = mod.discover(prefix)
            except TypeError:
                devices = mod.discover()
        else:
            # No high-level helper available; return empty list
            app.logger.info('BleComm has no scan/discover helper; returning empty list')

        # Normalize device list to objects with name/address/id
        out = []
        if isinstance(devices, list):
            for d in devices:
                if isinstance(d, dict):
                    name = d.get('name') or d.get('device') or ''
                    addr = d.get('address') or d.get('id') or ''
                    out.append({'name': name, 'address': addr, 'id': addr})
                else:
                    # could be bleak BLEDevice or string
                    try:
                        name = getattr(d, 'name', None) or str(d)
                        addr = getattr(d, 'address', None) or getattr(d, 'id', None) or ''
                        out.append({'name': name, 'address': addr, 'id': addr})
                    except Exception:
                        out.append({'name': str(d), 'address': '', 'id': ''})
        else:
            app.logger.debug('ble_scan: unexpected scan result type: %s', type(devices))

        # apply prefix filter server-side just in case
        if prefix:
            pf = prefix.lower()
            out = [x for x in out if (x.get('name') or '').lower().startswith(pf)]

        return jsonify(out)
    except Exception as e:
        app.logger.error(f"ble/scan failed: {e}")
        return make_response(jsonify({"error": str(e)}), 500)


@app.route('/ble/connect', methods=['POST'])
def ble_connect():
    """Connect to a BLE device. Accepts JSON body {id, name, auto} or form fields."""
    app.logger.info("ble_connect called")
    try:
        payload = parse_body()
        address = payload.get('id') or payload.get('address') or payload.get('addr') or ''
        name = payload.get('name') or payload.get('device') or ''
        filter = payload.get('filter') or payload.get('filter_name') or payload.get('filter-name') or ''
        # If the client did not supply id/address or name, fall back to configured BLE defaults
        if not address and not name:
            cfg = load_ble_config()
            address = cfg.address or ''
            device_name = cfg.device_name or ''
            filter = cfg.filter_name or ''
    # 'auto' present in payload but currently not used server-side
        ensure_ble_started()
        # Persist chosen BLE settings to config.json so they survive restarts
        try:
            cfg = load_ble_config()
            # update fields from payload (if present)
            if address:
                cfg.address = address
            if device_name:
                cfg.device_name = device_name
            if filter:
                cfg.filter_name = filter
            if 'auto' in payload:
                try:
                    cfg.auto_connect = bool(payload.get('auto'))
                except Exception:
                    pass
            # write back into comm.json (preserve other keys)
            try:
                base = {}
                if os.path.exists(COMM_PATH):
                    try:
                        with open(COMM_PATH, 'r', encoding='utf-8') as f:
                            base = json.load(f) or {}
                    except Exception:
                        base = {}
                base['ble'] = base.get('ble', {})
                base['ble']['auto-connect'] = cfg.auto_connect
                base['ble']['address'] = cfg.address
                base['ble']['device-name'] = cfg.device_name
                base['ble']['filter-name'] = cfg.filter_name

                with open(COMM_PATH, 'w', encoding='utf-8') as f:
                    json.dump(base, f, indent=2)
            except Exception as e:
                app.logger.debug(f"Failed to persist BLE config to comm.json: {e}")

        except Exception as e:
            app.logger.debug(f"ble_connect: failed to persist config: {e}")

        # attempt to connect using BleComm if available
        mod = importlib.import_module('src.BleComm')
        if hasattr(mod, 'connect') and callable(getattr(mod, 'connect')):
            try:
                res = mod.connect(cfg.address or cfg.device_name)
                return jsonify({'status': 'ok', 'connected': bool(res)})
            except Exception as e:
                app.logger.error(f'ble_connect: connect call failed: {e}')
                return make_response(jsonify({'error': str(e)}), 500)
        else:
            app.logger.warning('ble_connect: BleComm has no connect()')
            return make_response(jsonify({'error': 'connect not supported'}), 400)
    except Exception as e:
        app.logger.error(f"ble/connect failed: {e}")
        return make_response(jsonify({'error': str(e)}), 500)


@app.route('/ble/disconnect', methods=['POST'])
def ble_disconnect():
    try:
        ensure_ble_started()
        mod = importlib.import_module('src.BleComm')
        # If BleComm exposes get_status, consult it to see if a client exists
        try:
            st = getattr(mod, 'get_status') and mod.get_status()
        except Exception:
            st = None
        # if no connected client, return ok but don't call disconnect to avoid noisy logs
        if not st or not st.get('connected'):
            app.logger.info('ble_disconnect: no client present (nothing to do)')
            return jsonify({'status': 'ok', 'disconnected': False, 'reason': 'no client present'})

        if hasattr(mod, 'disconnect') and callable(getattr(mod, 'disconnect')):
            mod.disconnect()
            return jsonify({'status': 'ok', 'disconnected': True})
        else:
            app.logger.warning('ble_disconnect: BleComm has no disconnect()')
            return make_response(jsonify({'error': 'disconnect not supported'}), 400)
    except Exception as e:
        app.logger.error(f"ble/disconnect failed: {e}")
        return make_response(jsonify({'error': str(e)}), 500)


if __name__ == "__main__":
    # For Windows dev: this is fine. For production you could run with waitress/uvicorn.
    # Start BLE background loop if available
    # Load COMM settings (host/port and ble defaults) if present
    try:
        comm_cfg = load_comm_config()
        if 'host' in comm_cfg and comm_cfg.get('host'):
            HOST = str(comm_cfg.get('host'))
            app.logger.info(f"Loaded host from comm.json: {HOST}")
        if 'port' in comm_cfg and comm_cfg.get('port'):
            try:
                PORT = int(comm_cfg.get('port'))
                app.logger.info(f"Loaded port from comm.json: {PORT}")
            except Exception:
                pass
    except Exception as e:
        app.logger.debug(f"Failed to load comm.json: {e}")

    # CLI flags: support --gui and --server. By default (no flags) run server.
    import sys
    import threading

    args = sys.argv[1:]
    run_gui = '--gui' in args
    run_server = '--server' in args or not run_gui

    # load comm.json settings if present
    try:
        comm_cfg = load_comm_config()
        if 'host' in comm_cfg and comm_cfg.get('host'):
            HOST = str(comm_cfg.get('host'))
            app.logger.info(f"Loaded host from comm.json: {HOST}")
        if 'port' in comm_cfg and comm_cfg.get('port'):
            try:
                PORT = int(comm_cfg.get('port'))
                app.logger.info(f"Loaded port from comm.json: {PORT}")
            except Exception:
                pass
    except Exception as e:
        app.logger.debug(f"Failed to load comm.json: {e}")

    try:
        # ensure BleComm is started eagerly when running directly
        ensure_ble_started()
    except Exception as e:
        app.logger.error(f"Failed to start BleComm: {e}")

    # Acquire single-instance lock to prevent multiple running copies
    try:
        lock_file = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'data', 'dslr_director.lock'))
        fh = acquire_instance_lock(lock_file)
        if fh is None:
            app.logger.error(f"Another instance appears to be running (could not acquire lock {lock_file}). Exiting.")
            raise SystemExit(1)
        else:
            app.logger.info(f"Acquired instance lock: {lock_file}")
            import atexit as _atexit
            _atexit.register(lambda: release_instance_lock())
    except SystemExit:
        raise
    except Exception as e:
        app.logger.warning(f"Failed to acquire instance lock (continuing): {e}")

    # If server requested, prepare the server runner
    def _run_server():
        try:
            print(f"Starting Flask server on {HOST}:{PORT}")
            print("Routes registered:")
            for rule in app.url_map.iter_rules():
                print(f"  {rule.methods} {rule.rule}")
            # turn off reloader when running in thread
            app.run(host=HOST, port=PORT, debug=False, use_reloader=False)
        except Exception as e:
            app.logger.error(f"Flask server failed: {e}")

    # If both GUI and server requested, start server in background thread
    if run_server and run_gui:
        t = threading.Thread(target=_run_server, daemon=True)
        t.start()

    if run_server and not run_gui:
        _run_server()

    if run_gui:
        # Launch desktop GUI in main thread
        try:
            # import lazily to avoid Tkinter requirement when only running server
            import importlib
            gui_mod = None
            try:
                gui_mod = importlib.import_module('src.gui')
            except Exception:
                # Try fallback by file path loader
                from importlib.machinery import SourceFileLoader
                loader = SourceFileLoader('src.gui', os.path.join(os.path.dirname(__file__), 'gui.py'))
                spec = importlib.util.spec_from_loader(loader.name, loader)
                module = importlib.util.module_from_spec(spec)
                loader.exec_module(module)
                gui_mod = module

            if gui_mod is not None and hasattr(gui_mod, 'DSLRGui'):
                gui = gui_mod.DSLRGui()
                gui.mainloop()
        except Exception as e:
            app.logger.error(f"Failed to start GUI: {e}")
