#!/usr/bin/env python
# -*- coding: utf-8 -*-


"""
siphoney command-line entry point.

After `pip install siphoney` the user gets a `siphoney` command:

  siphoney init [DIR]   -- scaffold a working directory (default: cwd)
  siphoney run          -- start the honeypot in the foreground
  siphoney start        -- start in the background (Linux, macOS, Windows)
  siphoney stop         -- stop a backgrounded honeypot
  siphoney restart      -- restart (stop and start in the backgrounded) the honeypot
  siphoney status       -- show running status

The working directory is resolved in this order:
  1. DIR argument (for init) or --workdir flag (for other commands)
  2. SIPHONEY_WORKDIR environment variable
  3. Current working directory
"""

from __future__ import absolute_import, print_function

from argparse import ArgumentParser
from importlib import import_module
from os import (
    chdir,
    devnull,
    dup2,
    environ,
    kill,
    makedirs,
    name,
    remove,
    walk,
    O_RDWR,
)
from os import open as _open, chmod
from os.path import (
    abspath,
    dirname,
    exists,
    isdir,
    join,
    relpath,
)
from re import search, IGNORECASE
from shutil import copy2
from stat import S_IXUSR, S_IXGRP, S_IXOTH
import sys

from core.paths import bundled, get_workdir, workdir_path


# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------

def _ensure_dir(p):
    if not isdir(p):
        makedirs(p)


def _file_differs(a, b):
    with open(a, 'r') as fa, open(b, 'r') as fb:
        return fa.read() != fb.read()


def _copy_if_missing(src, dst, label=None):
    # Guard against copying a file onto itself (e.g. in a source checkout).
    if abspath(src) == abspath(dst):
        return False
    if not exists(dst) or _file_differs(src, dst):
        copy2(src, dst)
        if label is not None:
            print('  copied  {}'.format(label))
        return True
    if label is not None:
        print('  exists  {}'.format(label))
    return False


def _set_workdir(path):
    environ['SIPHONEY_WORKDIR'] = abspath(path)


def _pidfile():
    return workdir_path('siphoney.pid')


def _pid_running(pid):
    if name == 'nt':
        # os.kill(pid, 0) is a Unix idiom that doesn't work on Windows -
        # it raises SystemError. Use OpenProcess instead: a non-NULL handle
        # means the process exists; NULL means it doesn't (or we lack
        # permission, but that also means it's not ours to manage).
        import ctypes
        SYNCHRONIZE = 0x00100000
        handle = ctypes.windll.kernel32.OpenProcess(SYNCHRONIZE, False, pid)
        if not handle:
            return False
        ctypes.windll.kernel32.CloseHandle(handle)
        return True
    try:
        kill(pid, 0)
        return True
    except OSError:
        return False


def _locate_honeypot_script():
    """Return the absolute path to honeypot.py inside the installed package."""
    # honeypot.py is bundled inside the siphoney package directory alongside
    # this file (cli.py). Using __file__ avoids any dependency on pkg_resources
    # or importlib.resources and works on Python 2.7+.
    here = dirname(abspath(__file__))
    script_path = join(here, 'honeypot.py')
    if not exists(script_path):
        raise RuntimeError(
            'Cannot locate honeypot.py. '
            'Expected inside the siphoney package directory: {}'.format(here)
        )
    return script_path


def _run_main():
    """
    Import and run honeypot.main().

    honeypot.py lives inside the siphoney package directory. We import it
    directly using importlib so it runs as a proper module rather than a
    top-level script, avoiding any sys.path manipulation.
    """
    script_path = _locate_honeypot_script()
    script_dir = dirname(script_path)

    # Ensure the package directory is on sys.path so that honeypot.py's own
    # imports (from core.xxx, from output_plugins.xxx, etc.) resolve correctly.
    if script_dir not in sys.path:
        sys.path.insert(0, script_dir)

    # Import as 'honeypot'; clear any stale cached version first.
    if 'honeypot' in sys.modules:
        del sys.modules['honeypot']
    mod = import_module('honeypot')
    mod.main()


# ---------------------------------------------------------------------------
# init
# ---------------------------------------------------------------------------

def cmd_init(args):

    target = abspath(args.dir)
    _set_workdir(target)
    print('Initialising siphoney working directory: {}'.format(target))

    # Runtime directories
    for d in ('etc', 'log', 'data'):
        _ensure_dir(join(target, d))
        print('  mkdir  {}'.format(d))

    # Config templates
    for fname in ('honeypot.cfg.base',):
        src = bundled('etc', fname)
        dst = join(target, 'etc', fname)
        _copy_if_missing(src, dst, join('etc', fname))

    # docs/ tree - walk the entire bundled docs directory and copy everything.
    docs_src = bundled('docs')
    docs_dst = join(target, 'docs')
    for dirpath, dirnames, filenames in walk(docs_src):
        rel_dir = relpath(dirpath, docs_src)
        dst_dir = join(docs_dst, rel_dir) if rel_dir != '.' else docs_dst
        _ensure_dir(dst_dir)
        for fname in filenames:
            # geoipupdtask.ps1 is handled separately below (Windows only)
            if fname == 'geoipupdtask.ps1':
                continue
            src = join(dirpath, fname)
            dst = join(dst_dir, fname)
            rel = join('docs', rel_dir, fname) if rel_dir != '.' else join('docs', fname)
            _copy_if_missing(src, dst, rel)

    _copy_if_missing(bundled('test', 'baseline'), join(target, 'docs', 'baseline'), join('docs', 'baseline'))
    _copy_if_missing(bundled('test', 'test.py'), join(target, 'test.py'), 'test.py')
    if name == 'nt':
        _copy_if_missing(bundled('test', 'test.ps1'), join(target, 'test.ps1'), 'test.ps1')
    else:
        _copy_if_missing(bundled('test', 'test.sh'), join(target, 'test.sh'), 'test.sh')
        chmod(join(target, 'test.sh', S_IXUSR | S_IXGRP | S_IXOTH))
    _dockerfile = bundled('Dockerfile')
    if exists(_dockerfile):
        _copy_if_missing(_dockerfile, join(target, 'Dockerfile'), 'Dockerfile')
    # geoipupdtask.ps1 is a Windows Task Scheduler helper - copy on Windows only
    if name == 'nt':
        geo_src = bundled('docs', 'geoipupdtask.ps1')
        if exists(geo_src):
            _copy_if_missing(geo_src, join(target, 'geoipupdtask.ps1'), 'geoipupdtask.ps1')

    print()
    print('Next steps:')
    print('  1. Edit {} with your settings.'.format(join(target, 'etc', 'honeypot.cfg')))
    print('  2. Place GeoLite2-City.mmdb and GeoLite2-ASN.mmdb in {}.'.format(join(target, 'data')))
    print('  3. Run:  siphoney run   (foreground)')
    print('     or:   siphoney start (background)')


# ---------------------------------------------------------------------------
# run (foreground)
# ---------------------------------------------------------------------------

def cmd_run(args):
    # honeypot.py has its own argparse parser and will see sys.argv directly.
    # Keep only arguments that come after 'run' so honeypot.py's parser sees
    # only the options intended for it (e.g. siphoney run --port 5060).
    try:
        run_idx = sys.argv.index('run')
        sys.argv = [sys.argv[0]] + sys.argv[run_idx + 1:]
    except ValueError:
        sys.argv = [sys.argv[0]]

    # Output plugins use relative paths from CONFIG (e.g. 'log/siphoney.json')
    # without going through workdir_path(). Changing to the working directory
    # before starting ensures these paths resolve correctly.
    chdir(get_workdir())

    _run_main()


# ---------------------------------------------------------------------------
# start (background) -- cross-platform
# ---------------------------------------------------------------------------

def cmd_start(args):
    pidfile = _pidfile()

    # Check if already running
    if exists(pidfile):
        with open(pidfile) as fh:
            pid_str = fh.read().strip()
        if pid_str:
            try:
                pid = int(pid_str)
                if _pid_running(pid):
                    print('The honeypot is already running (PID {}).'.format(pid))
                    return
            except ValueError:
                pass

    print('Starting the honeypot...')
    if name == 'nt':
        _start_windows(pidfile)
    else:
        _start_posix(pidfile)


def _start_posix(pidfile):
    """Fork to background on POSIX (Linux, macOS)."""
    from os import fork, setsid
    pid = fork()
    if pid > 0:
        # Parent: record child PID and exit
        with open(pidfile, 'w') as fh:
            fh.write(str(pid))
        print('The honeypot was started successfully (PID {}).'.format(pid))
        return

    # Child: detach from terminal
    setsid()
    _devnull = _open(devnull, O_RDWR)
    for fd in (0, 1, 2):
        dup2(_devnull, fd)

    # Clear the 'start' subcommand from sys.argv before honeypot.py's parser runs
    try:
        start_idx = sys.argv.index('start')
        sys.argv = [sys.argv[0]] + sys.argv[start_idx + 1:]
    except ValueError:
        sys.argv = [sys.argv[0]]

    # Ensure relative paths in plugin configs resolve against the working directory
    chdir(get_workdir())

    _run_main()


def _start_windows(pidfile):
    """
    Spawn a detached, hidden background process on Windows.

    Uses pythonw.exe (the windowless interpreter) to avoid any console window.
    DETACHED_PROCESS + CREATE_NO_WINDOW ensure the child survives parent exit
    and has no console of its own.
    """
    from subprocess import Popen, STARTF_USESHOWWINDOW, STARTUPINFO

    # Defined as literals for Python 2.7/3.6 compatibility - these constants
    # were only added to subprocess as named attributes in Python 3.7.
    DETACHED_PROCESS = 0x00000008
    CREATE_NO_WINDOW = 0x08000000

    startupinfo = STARTUPINFO()
    startupinfo.dwFlags    |= STARTF_USESHOWWINDOW
    startupinfo.wShowWindow = 0  # SW_HIDE

    # pythonw.exe is the windowless interpreter Windows ships for background
    # scripts; more reliable than CREATE_NO_WINDOW for Twisted-based processes.
    pythonw = join(dirname(sys.executable), 'pythonw.exe')
    interpreter = pythonw if exists(pythonw) else sys.executable
    cmd = [interpreter, '-m', 'siphoney.cli', 'run']

    _devnull = open(devnull, 'wb')
    try:
        proc = Popen(
            cmd,
            creationflags=DETACHED_PROCESS | CREATE_NO_WINDOW,
            startupinfo=startupinfo,
            stdin=_devnull,
            stdout=_devnull,
            stderr=_devnull,
            env=environ.copy(),
        )
    finally:
        _devnull.close()

    with open(pidfile, 'w') as fh:
        fh.write(str(proc.pid))
    print('The honeypot was started successfully (PID {}).'.format(proc.pid))


# ---------------------------------------------------------------------------
# stop
# ---------------------------------------------------------------------------

def cmd_stop(args):
    pidfile = _pidfile()
    if not exists(pidfile):
        print('The honeypot is not running (no PID file).')
        return

    with open(pidfile) as fh:
        pid_str = fh.read().strip()

    if not pid_str:
        remove(pidfile)
        print('Empty PID file removed.')
        return

    try:
        pid = int(pid_str)
    except ValueError:
        remove(pidfile)
        print('Corrupt PID file removed.')
        return

    if not _pid_running(pid):
        remove(pidfile)
        print('Stale PID file (PID {} not running) removed.'.format(pid))
        return

    print('Stopping the honeypot (PID {})... '.format(pid), end='')

    if name == 'nt':
        _stop_windows(pid, pidfile)
    else:
        _stop_posix(pid, pidfile)


def _stop_posix(pid, pidfile):
    from signal import SIGKILL, SIGTERM
    from time import sleep
    kill(pid, SIGTERM)
    for _ in range(60):
        sleep(1)
        if not _pid_running(pid):
            print('Stopped.')
            remove(pidfile)
            return
    print()
    print('Did not stop gracefully; sending SIGKILL.')
    kill(pid, SIGKILL)
    remove(pidfile)


def _stop_windows(pid, pidfile):
    """
    Terminate the background honeypot process on Windows.

    The process was launched with pythonw.exe + CREATE_NO_WINDOW so it has no
    console. GenerateConsoleCtrlEvent only works for processes sharing a
    console or in the same process group - neither applies here. We therefore
    go straight to TerminateProcess via taskkill /F.
    """
    from subprocess import call

    _devnull = open(devnull, 'wb')
    try:
        ret = call(
            ['taskkill', '/F', '/PID', str(pid)],
            stdout=_devnull, stderr=_devnull,
        )
    finally:
        _devnull.close()

    if ret == 0 or not _pid_running(pid):
        print('Stopped.')
    else:
        print()
        print('Warning: taskkill returned {}, process may still be running.'.format(ret))
    remove(pidfile)


# ---------------------------------------------------------------------------
# restart
# ---------------------------------------------------------------------------

def cmd_restart(args):
    cmd_stop(args)
    cmd_start(args)


# ---------------------------------------------------------------------------
# status
# ---------------------------------------------------------------------------

def cmd_status(args):
    pidfile = _pidfile()
    if not exists(pidfile):
        print('The honeypot is not running.')
        return
    with open(pidfile) as fh:
        pid_str = fh.read().strip()
    if not pid_str:
        remove(pidfile)
        print('The honeypot is not running (empty PID file removed).')
        return
    try:
        pid = int(pid_str)
    except ValueError:
        remove(pidfile)
        print('Corrupt PID file removed.')
        return
    if _pid_running(pid):
        print('The honeypot is running (PID: {}).'.format(pid))
    else:
        remove(pidfile)
        print('The honeypot is not running (stale PID file for PID {} removed).'.format(pid))


# ---------------------------------------------------------------------------
# entry point
# ---------------------------------------------------------------------------

def main():
    parser = ArgumentParser(
        prog='siphoney',
        description='siphoney - A SIP Honeypot',
    )

    # Read version from honeypot.py without importing it (avoids Twisted import
    # at startup) and without using pkg_resources (deprecated in setuptools 81+).
    try:
        with open(_locate_honeypot_script()) as fh:
            _content = fh.read()
        _m = search(r"__VERSION__\s*=\s*['\"]([^'\"]+)['\"]", _content, IGNORECASE)
        _version = _m.group(1) if _m else 'unknown'
    except Exception:
        _version = 'unknown'

    parser.add_argument('-v', '--version', action='version', version=_version)
    parser.add_argument(
        '-w', '--workdir', default=None,
        help='Working directory (overrides SIPHONEY_WORKDIR and cwd)'
    )

    sub = parser.add_subparsers(dest='command', metavar='COMMAND')
    sub.required = True

    p_init = sub.add_parser('init', help='Scaffold a working directory')
    p_init.add_argument(
        'dir', nargs='?', default='.',
        help='Directory to initialize (default: current directory)'
    )
    p_init.set_defaults(func=cmd_init)

    p_run = sub.add_parser('run', help='Start the honeypot in the foreground')
    p_run.set_defaults(func=cmd_run)

    p_start = sub.add_parser('start', help='Start the honeypot in the background')
    p_start.set_defaults(func=cmd_start)

    sub.add_parser('stop', help='Stop the backgrounded honeypot').set_defaults(func=cmd_stop)

    p_restart = sub.add_parser(
        'restart', help='Restart (stop and start) the honeypot in the background'
    )
    p_restart.set_defaults(func=cmd_restart)

    for _p in (p_run, p_start, p_restart):
        _p.add_argument('-p', '--port', type=int, help='Port to listen on (default: 5060)')
        _p.add_argument('-l', '--logfile', help='Log file (default: stdout)')
        _p.add_argument('-s', '--sensor', help='Sensor name (default: hostname)')

    sub.add_parser('status',  help='Show running status').set_defaults(func=cmd_status)

    args = parser.parse_args()

    if args.workdir:
        _set_workdir(args.workdir)

    args.func(args)


if __name__ == '__main__':
    main()
