#!/usr/bin/env python3
"""SoulSync development launcher.

Starts the backend and Vite dev server together, restarts the backend when
backend source files change, and handles shutdown cleanly across platforms.
"""

from __future__ import annotations

import atexit
import os
import shutil
import signal
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path

ROOT_DIR = Path(__file__).resolve().parent
LOG_DIR = ROOT_DIR / 'logs'
GUNICORN_CONFIG = ROOT_DIR / 'gunicorn.dev.conf.py'
VITE_URL = os.environ.get('SOULSYNC_WEBUI_VITE_URL', 'http://127.0.0.1:5173').rstrip('/')
VITE_LOG_FILE = Path(os.environ.get('SOULSYNC_WEBUI_VITE_LOG', str(LOG_DIR / 'webui-vite.log')))

INCLUDED_SUFFIXES = {'.py', '.html', '.jinja', '.jinja2'}
SHUTDOWN_GRACE_SECONDS = int(os.environ.get('SOULSYNC_SHUTDOWN_GRACE_SECONDS', '10'))
FORCE_KILL_ON_SHUTDOWN = os.environ.get('SOULSYNC_FORCE_KILL_ON_SHUTDOWN', '1').lower() in {
    '1',
    'true',
    'yes',
    'on',
}

shutdown_requested = False
managed_processes: list[tuple[str, subprocess.Popen, object | None]] = []


def resolve_command(*candidates: str) -> str | None:
    for candidate in candidates:
        resolved = shutil.which(candidate)
        if resolved:
            return resolved
    return None


def is_excluded(path: Path) -> bool:
    try:
        relative = path.relative_to(ROOT_DIR)
    except ValueError:
        return False

    parts = relative.parts
    if not parts:
        return False

    if any(part == '__pycache__' for part in parts):
        return True
    if parts[0] in {'.git', 'logs'}:
        return True
    if len(parts) >= 2 and parts[0] == 'webui' and parts[1] == 'node_modules':
        return True
    if len(parts) >= 3 and parts[0] == 'webui' and parts[1] == 'static' and parts[2] == 'dist':
        return True
    return False


def build_backend_env(direct_mode: bool) -> dict[str, str]:
    env = os.environ.copy()
    env.setdefault('SOULSYNC_WEB_DEV_NO_CACHE', '1')
    env.setdefault('SOULSYNC_WEBUI_VITE_DEV', '1')
    env.setdefault('SOULSYNC_WEBUI_VITE_URL', VITE_URL)
    env.setdefault('SOULSYNC_WEBUI_VITE_LOG', str(VITE_LOG_FILE))
    env.setdefault('SOULSYNC_CONFIG_PATH', str(ROOT_DIR / 'config' / 'config.json'))

    if direct_mode:
        env.setdefault('SOULSYNC_WEB_BIND_HOST', '127.0.0.1')
        env.setdefault('SOULSYNC_WEB_BIND_PORT', '8008')

    return env


def start_process(label: str, cmd: list[str], *, log_file: Path | None = None, env: dict[str, str] | None = None) -> tuple[subprocess.Popen, object | None]:
    log_handle = None
    stdout = None
    stderr = None
    if log_file is not None:
        log_file.parent.mkdir(parents=True, exist_ok=True)
        log_handle = log_file.open('ab')
        stdout = log_handle
        stderr = log_handle

    creationflags = 0
    start_new_session = False
    if os.name == 'nt':
        creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
    else:
        start_new_session = True

    try:
        proc = subprocess.Popen(
            cmd,
            cwd=str(ROOT_DIR),
            env=env,
            stdin=subprocess.DEVNULL,
            stdout=stdout,
            stderr=stderr,
            creationflags=creationflags,
            start_new_session=start_new_session,
        )
    except Exception:
        if log_handle is not None:
            log_handle.close()
        raise

    managed_processes.append((label, proc, log_handle))
    return proc, log_handle


def wait_for_exit(proc: subprocess.Popen, seconds: int) -> bool:
    checks = max(1, int(seconds * 10))
    for _ in range(checks):
        if proc.poll() is not None:
            return True
        time.sleep(0.1)
    return proc.poll() is not None


def stop_process(label: str, proc: subprocess.Popen, log_handle: object | None) -> None:
    if proc.poll() is not None:
        if log_handle is not None:
            log_handle.close()
        return

    print(f'Stopping {label}...')

    try:
        if os.name == 'nt':
            proc.terminate()
        else:
            os.killpg(proc.pid, signal.SIGTERM)
    except ProcessLookupError:
        pass

    if not wait_for_exit(proc, SHUTDOWN_GRACE_SECONDS):
        if not FORCE_KILL_ON_SHUTDOWN:
            print(f'{label} did not exit in time; skipping forced kill for this test run.')
        else:
            print(f'{label} did not exit in time; forcing shutdown...')
            if os.name == 'nt':
                subprocess.run(
                    ['taskkill', '/T', '/F', '/PID', str(proc.pid)],
                    check=False,
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                )
            else:
                try:
                    os.killpg(proc.pid, signal.SIGKILL)
                except ProcessLookupError:
                    pass
            wait_for_exit(proc, 5)

    if log_handle is not None:
        log_handle.close()


def cleanup() -> None:
    global shutdown_requested

    if shutdown_requested:
        return
    shutdown_requested = True

    for label, proc, log_handle in reversed(managed_processes):
        stop_process(label, proc, log_handle)


def compute_backend_watch_state() -> str:
    rows: list[str] = []

    for dirpath, dirnames, filenames in os.walk(ROOT_DIR):
        current_dir = Path(dirpath)
        if is_excluded(current_dir):
            dirnames[:] = []
            continue

        dirnames[:] = [
            name
            for name in dirnames
            if not is_excluded(current_dir / name) and name != '__pycache__'
        ]

        for filename in filenames:
            path = current_dir / filename
            if path.suffix not in INCLUDED_SUFFIXES:
                continue
            try:
                stat = path.stat()
            except FileNotFoundError:
                continue
            rows.append(f'{stat.st_mtime_ns} {path}')

    return '\n'.join(sorted(rows))


def start_vite() -> subprocess.Popen:
    npm = resolve_command('npm', 'npm.cmd')
    if npm is None:
        raise SystemExit('npm is required to run the Vite dev server.')

    print(f'Starting Vite dev server at {VITE_URL}...')
    vite_cmd = [
        npm,
        '--prefix',
        str(ROOT_DIR / 'webui'),
        'run',
        'dev',
        '--',
        '--host',
        '127.0.0.1',
        '--port',
        '5173',
    ]
    proc, _ = start_process('Vite dev server', vite_cmd, log_file=VITE_LOG_FILE, env=os.environ.copy())
    return proc


def wait_for_vite_ready(vite_proc: subprocess.Popen) -> None:
    ready_url = f'{VITE_URL}/static/dist/@vite/client'
    vite_ready = False

    for _ in range(50):
        if vite_proc.poll() is not None:
            print('Warning: Vite dev server exited before it became ready.')
            break
        try:
            with urllib.request.urlopen(ready_url, timeout=1) as response:
                if response.status < 400:
                    vite_ready = True
                    break
        except (urllib.error.URLError, TimeoutError, OSError):
            pass
        time.sleep(0.2)

    if vite_ready:
        print('Vite dev server is ready.')
    else:
        print('Warning: timed out waiting for the Vite dev server.')
        print('The backend will still start, but the frontend may not hot-reload yet.')


def start_backend() -> tuple[subprocess.Popen, object | None]:
    backend_mode = os.environ.get('SOULSYNC_DEV_BACKEND', '').strip().lower()
    direct_mode = backend_mode == 'direct'
    gunicorn_mode = backend_mode == 'gunicorn'

    if not backend_mode:
        if os.name == 'nt':
            direct_mode = True
        elif resolve_command('gunicorn') is None:
            print('gunicorn not found; falling back to direct Python server.')
            direct_mode = True
        else:
            gunicorn_mode = True

    print('Starting SoulSync web server...')

    if gunicorn_mode:
        gunicorn = resolve_command('gunicorn')
        if gunicorn is None:
            raise SystemExit('gunicorn is not available but SOULSYNC_DEV_BACKEND=gunicorn was requested.')
        print(f'Using Gunicorn config: {GUNICORN_CONFIG}')
        cmd = [gunicorn, '-c', str(GUNICORN_CONFIG), 'wsgi:application']
    else:
        print('Using direct Python server for backend.')
        cmd = [sys.executable, str(ROOT_DIR / 'web_server.py')]

    proc, log_handle = start_process(
        'SoulSync web server',
        cmd,
        env=build_backend_env(direct_mode),
    )
    return proc, log_handle


def watch_and_run_backend() -> None:
    last_state = compute_backend_watch_state()
    backend_proc, backend_log = start_backend()

    try:
        while not shutdown_requested:
            time.sleep(1)

            if backend_proc.poll() is not None:
                print('SoulSync web server exited. Restarting...')
                stop_process('SoulSync web server', backend_proc, backend_log)
                managed_processes.pop()
                backend_proc, backend_log = start_backend()
                last_state = compute_backend_watch_state()
                continue

            current_state = compute_backend_watch_state()
            if current_state != last_state:
                print('Detected backend file changes. Restarting SoulSync web server...')
                last_state = current_state
                stop_process('SoulSync web server', backend_proc, backend_log)
                managed_processes.pop()
                backend_proc, backend_log = start_backend()
    finally:
        if backend_proc.poll() is None:
            stop_process('SoulSync web server', backend_proc, backend_log)
            if managed_processes:
                managed_processes.pop()


def main() -> int:
    if not (ROOT_DIR / 'webui' / 'node_modules').is_dir():
        print('webui/node_modules is missing.')
        print('Run: cd webui && npm ci')
        return 1

    vite_proc = start_vite()
    try:
        wait_for_vite_ready(vite_proc)
        print(f'Vite log: {VITE_LOG_FILE}')
        print('Backend file watching is enabled.')
        watch_and_run_backend()
    finally:
        cleanup()
    return 0


def _handle_signal(signum: int, _frame) -> None:
    raise SystemExit(130 if signum == signal.SIGINT else 143)


signal.signal(signal.SIGINT, _handle_signal)
if hasattr(signal, 'SIGTERM'):
    signal.signal(signal.SIGTERM, _handle_signal)
if hasattr(signal, 'SIGBREAK'):
    signal.signal(signal.SIGBREAK, _handle_signal)

atexit.register(cleanup)

if __name__ == '__main__':
    raise SystemExit(main())
