"""Watchdog - Monitors and restores scheduled jobs (cron/launchd) if deleted."""

import contextlib
import logging
import plistlib
import shutil
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Optional

import click

from .common import audit_log as _base_audit_log
from .common import get_log_dir, read_secure_file, write_secure_file

logger = logging.getLogger(__name__)

# =============================================================================
# CONFIGURATION & CONSTANTS
# =============================================================================

SUBPROCESS_TIMEOUT = 60

# launchd constants for macOS
LAUNCHD_SYNC_LABEL = "com.nextdns-blocker.sync"
LAUNCHD_WATCHDOG_LABEL = "com.nextdns-blocker.watchdog"


def is_macos() -> bool:
    """Check if running on macOS."""
    return sys.platform == "darwin"


def get_launch_agents_dir() -> Path:
    """Get the LaunchAgents directory for the current user."""
    return Path.home() / "Library" / "LaunchAgents"


def get_sync_plist_path() -> Path:
    """Get the path to the sync plist file."""
    return get_launch_agents_dir() / f"{LAUNCHD_SYNC_LABEL}.plist"


def get_watchdog_plist_path() -> Path:
    """Get the path to the watchdog plist file."""
    return get_launch_agents_dir() / f"{LAUNCHD_WATCHDOG_LABEL}.plist"


def get_disabled_file() -> Path:
    """Get the watchdog disabled state file path."""
    return get_log_dir() / ".watchdog_disabled"


def get_executable_path() -> str:
    """Get the full path to the nextdns-blocker executable.

    Returns a string suitable for shell commands (cron).
    For launchd, use get_executable_args() instead.
    """
    exe_path = shutil.which("nextdns-blocker")
    if exe_path:
        return exe_path
    # Fallback to sys.executable module invocation
    return f"{sys.executable} -m nextdns_blocker"


def get_executable_args() -> list[str]:
    """Get the executable as a list of arguments for launchd/subprocess.

    Returns a list suitable for subprocess.run() and launchd ProgramArguments.
    """
    exe_path = shutil.which("nextdns-blocker")
    if exe_path:
        return [exe_path]
    # Fallback to sys.executable module invocation
    return [sys.executable, "-m", "nextdns_blocker"]


def get_cron_sync() -> str:
    """Get the sync cron job definition."""
    log_dir = get_log_dir()
    exe = get_executable_path()
    log_file = str(log_dir / "cron.log")
    return f'*/2 * * * * {exe} sync >> "{log_file}" 2>&1'


def get_cron_watchdog() -> str:
    """Get the watchdog cron job definition."""
    log_dir = get_log_dir()
    exe = get_executable_path()
    log_file = str(log_dir / "wd.log")
    return f'* * * * * {exe} watchdog check >> "{log_file}" 2>&1'


def audit_log(action: str, detail: str = "") -> None:
    """Wrapper for audit_log with WD prefix."""
    _base_audit_log(action, detail, prefix="WD")


# =============================================================================
# DISABLED STATE MANAGEMENT
# =============================================================================


def is_disabled() -> bool:
    """Check if watchdog is temporarily or permanently disabled."""
    disabled_file = get_disabled_file()
    content = read_secure_file(disabled_file)
    if not content:
        return False

    try:
        if content == "permanent":
            return True

        disabled_until = datetime.fromisoformat(content)
        if datetime.now() < disabled_until:
            return True

        # Expired, clean up
        _remove_disabled_file()
        return False
    except ValueError:
        return False


def get_disabled_remaining() -> str:
    """Get remaining disabled time as human-readable string."""
    disabled_file = get_disabled_file()
    content = read_secure_file(disabled_file)
    if not content:
        return ""

    try:
        if content == "permanent":
            return "permanently"

        disabled_until = datetime.fromisoformat(content)
        remaining = disabled_until - datetime.now()

        if remaining.total_seconds() <= 0:
            _remove_disabled_file()
            return ""

        mins = int(remaining.total_seconds() // 60)
        return f"{mins} min" if mins > 0 else "< 1 min"
    except ValueError:
        return ""


def _remove_disabled_file() -> None:
    """Remove the disabled file safely."""
    try:
        get_disabled_file().unlink(missing_ok=True)
    except OSError as e:
        logger.debug(f"Failed to remove disabled file: {e}")


def set_disabled(minutes: Optional[int] = None) -> None:
    """Disable watchdog temporarily or permanently."""
    disabled_file = get_disabled_file()
    if minutes:
        disabled_until = datetime.now().replace(microsecond=0) + timedelta(minutes=minutes)
        write_secure_file(disabled_file, disabled_until.isoformat())
        audit_log("WD_DISABLED", f"{minutes} minutes until {disabled_until.isoformat()}")
    else:
        write_secure_file(disabled_file, "permanent")
        audit_log("WD_DISABLED", "permanent")


def clear_disabled() -> bool:
    """Re-enable watchdog. Returns True if was disabled."""
    disabled_file = get_disabled_file()
    if disabled_file.exists():
        _remove_disabled_file()
        audit_log("WD_ENABLED", "Manual enable")
        return True
    return False


# =============================================================================
# CRON MANAGEMENT
# =============================================================================


def get_crontab() -> str:
    """Get the current user's crontab contents."""
    try:
        result = subprocess.run(
            ["crontab", "-l"], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT
        )
        return result.stdout if result.returncode == 0 else ""
    except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired):
        return ""


def set_crontab(content: str) -> bool:
    """Set the user's crontab contents."""
    try:
        result = subprocess.run(
            ["crontab", "-"],
            input=content,
            text=True,
            capture_output=True,
            timeout=SUBPROCESS_TIMEOUT,
        )
        return result.returncode == 0
    except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired) as e:
        logger.warning(f"Failed to set crontab: {e}")
        return False


def has_sync_cron(crontab: str) -> bool:
    """Check if sync cron job is present."""
    return "nextdns-blocker sync" in crontab


def has_watchdog_cron(crontab: str) -> bool:
    """Check if watchdog cron job is present."""
    return "nextdns-blocker watchdog" in crontab


def filter_our_cron_jobs(crontab: str) -> list[str]:
    """Remove our cron jobs from crontab, keeping other entries."""
    return [line for line in crontab.split("\n") if "nextdns-blocker" not in line and line.strip()]


# =============================================================================
# LAUNCHD MANAGEMENT (macOS)
# =============================================================================


def generate_plist(
    label: str,
    program_args: list[str],
    start_interval: int,
    log_file: Path,
) -> bytes:
    """Generate plist content for a launchd job."""
    plist_dict: dict[str, Any] = {
        "Label": label,
        "ProgramArguments": program_args,
        "StartInterval": start_interval,
        "RunAtLoad": True,
        "KeepAlive": {"SuccessfulExit": False},  # Restart if process crashes
        "StandardOutPath": str(log_file),
        "StandardErrorPath": str(log_file),
        "EnvironmentVariables": {"PATH": "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin"},
    }
    return plistlib.dumps(plist_dict)


def load_launchd_job(plist_path: Path) -> bool:
    """Load a launchd job from a plist file."""
    try:
        # First unload if exists (ignore errors)
        subprocess.run(
            ["launchctl", "unload", str(plist_path)],
            capture_output=True,
            timeout=SUBPROCESS_TIMEOUT,
        )
        # Then load
        result = subprocess.run(
            ["launchctl", "load", str(plist_path)],
            capture_output=True,
            text=True,
            timeout=SUBPROCESS_TIMEOUT,
        )
        return result.returncode == 0
    except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired) as e:
        logger.warning(f"Failed to load launchd job: {e}")
        return False


def unload_launchd_job(plist_path: Path, label: str) -> bool:
    """Unload a launchd job and remove the plist file."""
    try:
        # Unload the job
        subprocess.run(
            ["launchctl", "unload", str(plist_path)],
            capture_output=True,
            timeout=SUBPROCESS_TIMEOUT,
        )
        # Remove plist file
        if plist_path.exists():
            plist_path.unlink()
        return True
    except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired) as e:
        logger.warning(f"Failed to unload launchd job {label}: {e}")
        return False


def is_launchd_job_loaded(label: str) -> bool:
    """Check if a launchd job is currently loaded."""
    try:
        result = subprocess.run(
            ["launchctl", "list", label],
            capture_output=True,
            text=True,
            timeout=SUBPROCESS_TIMEOUT,
        )
        return result.returncode == 0
    except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired):
        return False


# =============================================================================
# PLATFORM-SPECIFIC HELPERS
# =============================================================================


def _install_cron_jobs() -> None:
    """Install cron jobs (Linux)."""
    crontab = get_crontab()
    lines = filter_our_cron_jobs(crontab)
    lines.extend([get_cron_sync(), get_cron_watchdog()])

    if set_crontab("\n".join(lines) + "\n"):
        audit_log("CRON_INSTALLED", "Manual install")
        click.echo("\n  cron installed")
        click.echo("    sync       every 2 min")
        click.echo("    watchdog   every 1 min\n")
    else:
        click.echo("  error: cron install failed", err=True)
        sys.exit(1)


def _uninstall_cron_jobs() -> None:
    """Uninstall cron jobs (Linux)."""
    crontab = get_crontab()
    lines = filter_our_cron_jobs(crontab)
    new_content = "\n".join(lines) + "\n" if lines else ""

    if set_crontab(new_content):
        audit_log("CRON_UNINSTALLED", "Manual uninstall")
        click.echo("\n  Cron jobs removed\n")
    else:
        click.echo("  error: failed to remove cron jobs", err=True)
        sys.exit(1)


def _status_cron_jobs() -> None:
    """Display cron job status (Linux)."""
    crontab = get_crontab()
    has_sync = has_sync_cron(crontab)
    has_wd = has_watchdog_cron(crontab)
    disabled_remaining = get_disabled_remaining()

    click.echo("\n  cron")
    click.echo("  ----")
    click.echo(f"    sync       {'ok' if has_sync else 'missing'}")
    click.echo(f"    watchdog   {'ok' if has_wd else 'missing'}")

    if disabled_remaining:
        click.echo(f"\n  watchdog: DISABLED ({disabled_remaining})")
    else:
        status = "active" if (has_sync and has_wd) else "compromised"
        click.echo(f"\n  status: {status}")
    click.echo()


def _check_cron_jobs() -> None:
    """Check and restore cron jobs if missing (Linux)."""
    crontab = get_crontab()
    restored = False

    # Check and restore sync cron
    if not has_sync_cron(crontab):
        audit_log("CRON_DELETED", "Sync cron missing")
        new_crontab = crontab.strip()
        new_crontab = (new_crontab + "\n" if new_crontab else "") + get_cron_sync() + "\n"
        if set_crontab(new_crontab):
            click.echo("  sync cron restored")
            restored = True
        else:
            click.echo("  warning: failed to restore sync cron", err=True)

    # Check and restore watchdog cron
    if not has_watchdog_cron(crontab):
        audit_log("WD_CRON_DELETED", "Watchdog cron missing")
        crontab = get_crontab()
        new_crontab = crontab.strip()
        new_crontab = (new_crontab + "\n" if new_crontab else "") + get_cron_watchdog() + "\n"
        if set_crontab(new_crontab):
            click.echo("  watchdog cron restored")
            restored = True
        else:
            click.echo("  warning: failed to restore watchdog cron", err=True)

    # Run sync if cron was restored
    if restored:
        _run_sync_after_restore()


def _write_plist_file(plist_path: Path, content: bytes) -> bool:
    """Write plist file with correct permissions (0o644)."""
    try:
        plist_path.write_bytes(content)
        plist_path.chmod(0o644)
        return True
    except OSError as e:
        logger.warning(f"Failed to write plist file {plist_path}: {e}")
        return False


def _safe_unlink(path: Path) -> None:
    """Safely remove a file, ignoring errors."""
    try:
        if path.exists():
            path.unlink()
    except OSError as e:
        logger.debug(f"Failed to remove file {path}: {e}")


def _install_launchd_jobs() -> None:
    """Install launchd jobs (macOS)."""
    launch_agents_dir = get_launch_agents_dir()
    launch_agents_dir.mkdir(parents=True, exist_ok=True)

    exe_args = get_executable_args()
    log_dir = get_log_dir()
    log_dir.mkdir(parents=True, exist_ok=True)

    # Generate and write sync plist
    sync_plist = get_sync_plist_path()
    sync_content = generate_plist(
        label=LAUNCHD_SYNC_LABEL,
        program_args=exe_args + ["sync"],
        start_interval=120,  # 2 minutes
        log_file=log_dir / "launchd_sync.log",
    )
    if not _write_plist_file(sync_plist, sync_content):
        click.echo("  error: failed to write sync plist", err=True)
        sys.exit(1)

    # Generate and write watchdog plist
    watchdog_plist = get_watchdog_plist_path()
    watchdog_content = generate_plist(
        label=LAUNCHD_WATCHDOG_LABEL,
        program_args=exe_args + ["watchdog", "check"],
        start_interval=60,  # 1 minute
        log_file=log_dir / "launchd_wd.log",
    )
    if not _write_plist_file(watchdog_plist, watchdog_content):
        # Clean up sync plist that was already written
        _safe_unlink(sync_plist)
        click.echo("  error: failed to write watchdog plist", err=True)
        sys.exit(1)

    # Load the jobs
    success_sync = load_launchd_job(sync_plist)
    if not success_sync:
        # Sync failed, clean up and exit early
        _safe_unlink(sync_plist)
        _safe_unlink(watchdog_plist)
        click.echo("  error: failed to load sync launchd job", err=True)
        sys.exit(1)

    success_wd = load_launchd_job(watchdog_plist)
    if not success_wd:
        # Watchdog failed, unload sync and clean up
        with contextlib.suppress(OSError, subprocess.SubprocessError, subprocess.TimeoutExpired):
            subprocess.run(
                ["launchctl", "unload", str(sync_plist)],
                capture_output=True,
                timeout=SUBPROCESS_TIMEOUT,
            )
        _safe_unlink(sync_plist)
        _safe_unlink(watchdog_plist)
        click.echo("  error: failed to load watchdog launchd job", err=True)
        sys.exit(1)

    audit_log("LAUNCHD_INSTALLED", "Manual install")
    click.echo("\n  launchd jobs installed")
    click.echo("    sync       every 2 min")
    click.echo("    watchdog   every 1 min\n")


def _uninstall_launchd_jobs() -> None:
    """Uninstall launchd jobs (macOS)."""
    sync_plist = get_sync_plist_path()
    watchdog_plist = get_watchdog_plist_path()

    success_sync = unload_launchd_job(sync_plist, LAUNCHD_SYNC_LABEL)
    success_wd = unload_launchd_job(watchdog_plist, LAUNCHD_WATCHDOG_LABEL)

    audit_log("LAUNCHD_UNINSTALLED", "Manual uninstall")

    if success_sync and success_wd:
        click.echo("\n  launchd jobs removed\n")
    elif not success_sync and not success_wd:
        click.echo("\n  warning: failed to unload both launchd jobs\n", err=True)
    elif not success_sync:
        click.echo("\n  watchdog removed, warning: failed to unload sync job\n", err=True)
    else:
        click.echo("\n  sync removed, warning: failed to unload watchdog job\n", err=True)


def _status_launchd_jobs() -> None:
    """Display launchd job status (macOS)."""
    has_sync = is_launchd_job_loaded(LAUNCHD_SYNC_LABEL)
    has_wd = is_launchd_job_loaded(LAUNCHD_WATCHDOG_LABEL)
    disabled_remaining = get_disabled_remaining()

    click.echo("\n  launchd")
    click.echo("  -------")
    click.echo(f"    sync       {'ok' if has_sync else 'missing'}")
    click.echo(f"    watchdog   {'ok' if has_wd else 'missing'}")

    if disabled_remaining:
        click.echo(f"\n  watchdog: DISABLED ({disabled_remaining})")
    else:
        status = "active" if (has_sync and has_wd) else "compromised"
        click.echo(f"\n  status: {status}")
    click.echo()


def _check_launchd_jobs() -> None:
    """Check and restore launchd jobs if missing (macOS)."""
    restored = False

    # Check sync job
    if not is_launchd_job_loaded(LAUNCHD_SYNC_LABEL):
        audit_log("LAUNCHD_DELETED", "Sync job missing")
        sync_plist = get_sync_plist_path()
        if sync_plist.exists():
            if load_launchd_job(sync_plist):
                click.echo("  sync launchd job restored")
                restored = True
            else:
                click.echo("  warning: failed to restore sync launchd job", err=True)
        else:
            # Recreate plist
            if _create_sync_plist():
                if load_launchd_job(sync_plist):
                    click.echo("  sync launchd job recreated")
                    restored = True
                else:
                    # Clean up orphaned plist
                    _safe_unlink(sync_plist)
                    click.echo("  warning: failed to load sync launchd job", err=True)
            else:
                click.echo("  warning: failed to create sync plist", err=True)

    # Check watchdog job
    if not is_launchd_job_loaded(LAUNCHD_WATCHDOG_LABEL):
        audit_log("LAUNCHD_WD_DELETED", "Watchdog job missing")
        watchdog_plist = get_watchdog_plist_path()
        if watchdog_plist.exists():
            if load_launchd_job(watchdog_plist):
                click.echo("  watchdog launchd job restored")
                restored = True
            else:
                click.echo("  warning: failed to restore watchdog launchd job", err=True)
        else:
            # Recreate plist
            if _create_watchdog_plist():
                if load_launchd_job(watchdog_plist):
                    click.echo("  watchdog launchd job recreated")
                    restored = True
                else:
                    # Clean up orphaned plist
                    _safe_unlink(watchdog_plist)
                    click.echo("  warning: failed to load watchdog launchd job", err=True)
            else:
                click.echo("  warning: failed to create watchdog plist", err=True)

    # Run sync if jobs were restored
    if restored:
        _run_sync_after_restore()


def _create_sync_plist() -> bool:
    """Create sync plist file. Returns True on success."""
    exe_args = get_executable_args()
    log_dir = get_log_dir()
    log_dir.mkdir(parents=True, exist_ok=True)

    sync_plist = get_sync_plist_path()
    sync_plist.parent.mkdir(parents=True, exist_ok=True)
    sync_content = generate_plist(
        label=LAUNCHD_SYNC_LABEL,
        program_args=exe_args + ["sync"],
        start_interval=120,
        log_file=log_dir / "launchd_sync.log",
    )
    return _write_plist_file(sync_plist, sync_content)


def _create_watchdog_plist() -> bool:
    """Create watchdog plist file. Returns True on success."""
    exe_args = get_executable_args()
    log_dir = get_log_dir()
    log_dir.mkdir(parents=True, exist_ok=True)

    watchdog_plist = get_watchdog_plist_path()
    watchdog_plist.parent.mkdir(parents=True, exist_ok=True)
    watchdog_content = generate_plist(
        label=LAUNCHD_WATCHDOG_LABEL,
        program_args=exe_args + ["watchdog", "check"],
        start_interval=60,
        log_file=log_dir / "launchd_wd.log",
    )
    return _write_plist_file(watchdog_plist, watchdog_content)


def _run_sync_after_restore() -> None:
    """Run sync command after restoring scheduled jobs."""
    try:
        exe_args = get_executable_args()
        subprocess.run(exe_args + ["sync"], timeout=SUBPROCESS_TIMEOUT)
    except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired) as e:
        logger.warning(f"Failed to run sync after restore: {e}")


# =============================================================================
# CLICK CLI
# =============================================================================


@click.group()
def watchdog_cli() -> None:
    """Watchdog commands for scheduled job management (cron/launchd)."""
    pass


@watchdog_cli.command("check")
def cmd_check() -> None:
    """Check and restore scheduled jobs if missing."""
    if is_disabled():
        remaining = get_disabled_remaining()
        click.echo(f"  watchdog disabled ({remaining})")
        return

    if is_macos():
        _check_launchd_jobs()
    else:
        _check_cron_jobs()


@watchdog_cli.command("install")
def cmd_install() -> None:
    """Install sync and watchdog scheduled jobs."""
    if is_macos():
        _install_launchd_jobs()
    else:
        _install_cron_jobs()


@watchdog_cli.command("uninstall")
def cmd_uninstall() -> None:
    """Remove scheduled jobs."""
    if is_macos():
        _uninstall_launchd_jobs()
    else:
        _uninstall_cron_jobs()


@watchdog_cli.command("status")
def cmd_status() -> None:
    """Display current scheduled job status."""
    if is_macos():
        _status_launchd_jobs()
    else:
        _status_cron_jobs()


@watchdog_cli.command("disable")
@click.argument("minutes", required=False, type=click.IntRange(min=1))
def cmd_disable(minutes: Optional[int]) -> None:
    """Disable watchdog temporarily or permanently."""
    set_disabled(minutes)

    if minutes:
        disabled_until = datetime.now().replace(microsecond=0) + timedelta(minutes=minutes)
        click.echo(f"\n  Watchdog disabled for {minutes} minutes")
        click.echo(f"  Re-enables at: {disabled_until.strftime('%H:%M')}")
    else:
        click.echo("\n  Watchdog disabled permanently")
        click.echo("  Use 'enable' to re-enable")
    click.echo()


@watchdog_cli.command("enable")
def cmd_enable() -> None:
    """Re-enable watchdog."""
    if clear_disabled():
        click.echo("\n  Watchdog enabled\n")
    else:
        click.echo("\n  Watchdog is already enabled\n")


# Make watchdog available as subcommand of main CLI
def register_watchdog(main_group: click.Group) -> None:
    """Register watchdog commands as subcommand of main CLI."""
    main_group.add_command(watchdog_cli, name="watchdog")


# Alias for backward compatibility with tests
main = watchdog_cli
