#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""confmon - periodic config drift monitor.

Entry point installed as `sysak confmon`.
Daemon: adopts oomCollector paradigm (systemd simple service).
"""

from __future__ import print_function

import json
import os
import signal
import subprocess
import sys
import threading
import time

sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))

from confmon_lib.differ import diff, config_fingerprint
from confmon_lib.reporter import Reporter

# --- defaults --------------------------------------------------------

DEFAULT_CONFIG = "/etc/sysak/confmon.json"
DEFAULTS = {
    "interval": 3600,
    "tier": "all",
    "ilog_file": "/var/sysom/ilog/confmon.log",
    "state_dir": "/var/log/sysak/confmon",
    "heartbeat": True,
    "max_item_value": 2048,
    "max_line_bytes": 500000,
}
METADATA_URL = "http://100.100.100.200/latest/meta-data/"
CONFSCAN_CMD = "/usr/bin/sysak confscan"

# --- globals (signal handling) ----------------------------------------

_running = True
_reload = False
_wake = threading.Event()


def _sig_term(signo, frame):
    global _running
    _running = False
    _wake.set()


def _sig_hup(signo, frame):
    global _reload
    _reload = True
    _wake.set()


# --- config -----------------------------------------------------------

def load_config(path):
    cfg = dict(DEFAULTS)
    if os.path.isfile(path):
        try:
            with open(path) as fp:
                user = json.load(fp)
            if isinstance(user, dict):
                cfg.update(user)
        except Exception as e:
            print("confmon: config %s: %s, using defaults" % (path, e),
                  file=sys.stderr)
    return cfg


# --- metadata (cloud instance identity) -------------------------------

def _curl_metadata(path):
    try:
        proc = subprocess.Popen(
            ["curl", "-s", "-m", "3", METADATA_URL + path],
            stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        out, _ = proc.communicate()
        if proc.returncode == 0:
            return out.decode("utf-8", "replace").strip()
    except Exception:
        pass
    return ""


def get_base_fields():
    return {
        "uid": _curl_metadata("owner-account-id"),
        "instance": _curl_metadata("instance-id"),
    }


# --- snapshot io ------------------------------------------------------

def _baseline_path(cfg):
    return os.path.join(cfg["state_dir"], "confmon_baseline.json")


def _curr_path(cfg):
    return os.path.join(cfg["state_dir"], "confmon_curr.json")


def _load_json(path):
    try:
        with open(path) as fp:
            return json.load(fp)
    except Exception:
        return None


def _collect(cfg):
    """Run confscan as subprocess, write to curr path."""
    out = _curr_path(cfg) + ".tmp"
    confscan = cfg.get("confscan_cmd", CONFSCAN_CMD)
    cmd = "%s -t %s -o %s" % (confscan, cfg["tier"], out)
    try:
        proc = subprocess.Popen(
            cmd, shell=True,
            stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        _, err = proc.communicate()
        if proc.returncode != 0:
            print("confmon: confscan failed (exit %d): %s"
                  % (proc.returncode, err.decode("utf-8", "replace")[:200]),
                  file=sys.stderr)
            return None
    except Exception as e:
        print("confmon: confscan spawn failed: %s" % e, file=sys.stderr)
        return None
    # atomic rename
    final = _curr_path(cfg)
    try:
        os.rename(out, final)
    except OSError as e:
        print("confmon: rename failed: %s" % e, file=sys.stderr)
        return None
    return _load_json(final)


# --- main loop --------------------------------------------------------

def run(config_path):
    global _running, _reload

    cfg = load_config(config_path)
    _ensure_dir(cfg["state_dir"])

    print("confmon: starting, interval=%ds tier=%s"
          % (cfg["interval"], cfg["tier"]), file=sys.stderr)

    base_fields = get_base_fields()
    reporter = Reporter(cfg["ilog_file"], base_fields,
                        max_item_value=cfg["max_item_value"],
                        max_line_bytes=cfg["max_line_bytes"])

    while _running:
        if _reload:
            _reload = False
            cfg = load_config(config_path)
            reporter = Reporter(cfg["ilog_file"], base_fields,
                                max_item_value=cfg["max_item_value"],
                                max_line_bytes=cfg["max_line_bytes"])
            print("confmon: config reloaded", file=sys.stderr)

        # 1. collect
        curr = _collect(cfg)
        if curr is None:
            _interruptible_sleep(cfg["interval"])
            continue

        meta = curr.get("meta", {})
        total = curr.get("summary", {}).get("total", 0)

        # 2. load baseline
        baseline = _load_json(_baseline_path(cfg))
        if baseline is None:
            # first run
            fp_new = config_fingerprint(curr)
            reporter.baseline_init(meta, total, fp_new)
            _save_baseline(cfg, curr)
            print("confmon: baseline initialized (%d items)" % total,
                  file=sys.stderr)
            _interruptible_sleep(cfg["interval"])
            continue

        # 3. diff
        result = diff(baseline, curr)

        # 4. report
        if result.kind == "config_changed":
            reporter.config_changed(result.old_config_sha256,
                                    result.config_sha256)
            print("confmon: config whitelist changed, resetting baseline",
                  file=sys.stderr)
        else:
            counts = result.counts()
            if result.items:
                reporter.drift_items(result.items)
            if cfg["heartbeat"] or result.items:
                reporter.heartbeat(
                    meta, counts,
                    boot_changed=result.boot_changed,
                    volatile_changed=result.volatile_changed,
                    config_sha256=result.config_sha256)

        # 5. update baseline (atomic)
        _save_baseline(cfg, curr)

        # 6. sleep
        _interruptible_sleep(cfg["interval"])

    print("confmon: stopped", file=sys.stderr)


def _save_baseline(cfg, snapshot):
    path = _baseline_path(cfg)
    tmp = path + ".tmp"
    data = json.dumps(snapshot, ensure_ascii=False).encode("utf-8")
    with open(tmp, "wb") as fp:
        fp.write(data)
    os.rename(tmp, path)


def _interruptible_sleep(seconds):
    _wake.clear()
    _wake.wait(seconds)


def _ensure_dir(d):
    if not d:
        return
    try:
        os.makedirs(d)
    except OSError:
        pass


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

def main():
    config_path = DEFAULT_CONFIG
    # simple arg: -c <config>
    args = sys.argv[1:]
    if "-c" in args:
        idx = args.index("-c")
        if idx + 1 < len(args):
            config_path = args[idx + 1]

    if "-h" in args or "--help" in args:
        print("Usage: sysak confmon [-c <config.json>]")
        print("  Periodic config drift monitor (daemon).")
        print("  Config: %s" % DEFAULT_CONFIG)
        return 0

    signal.signal(signal.SIGTERM, _sig_term)
    signal.signal(signal.SIGINT, _sig_term)
    signal.signal(signal.SIGHUP, _sig_hup)

    try:
        run(config_path)
    except KeyboardInterrupt:
        pass
    return 0


if __name__ == "__main__":
    sys.exit(main())
