#!/usr/bin/env python3
"""
bacula-mock - Command line tool for the Bacula Mock Server

Usage:
    bacula-mock init [CLIENTS] [ERROR_RATE]
    bacula-mock reset
    bacula-mock start | stop | restart
    bacula-mock health
    bacula-mock token
    bacula-mock clients [LIMIT]
    bacula-mock jobs [LIMIT]
    bacula-mock jobs-failed [LIMIT]
    bacula-mock jobs-for CLIENTID [LIMIT]
    bacula-mock joblog JOBID
    bacula-mock jobtotals
    bacula-mock pools
    bacula-mock dir-status
    bacula-mock client NAME
    bacula-mock client-status NAME
    bacula-mock config-clients
    bacula-mock export-csv
    bacula-mock export-errors
    bacula-mock smoke-test

Add --raw to any query command for unformatted JSON output.

Version:     1.0.0
Copyright 2026 faaleoleo dev team

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice,
   this list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
   this list of conditions and the following disclaimer in the documentation
   and/or other materials provided with the distribution.

3. Neither the name of the copyright holder nor the names of its contributors
   may be used to endorse or promote products derived from this software
   without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
THE POSSIBILITY OF SUCH DAMAGE.

"Bacula" is a registered trademark of Bacula Systems SA.
This software is not affiliated with or endorsed by Bacula Systems SA.
"""

import base64
import csv
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request


# ---------------------------------------------------------------------------
# Configuration – overridable via .env or environment variables
# ---------------------------------------------------------------------------

DEFAULT_HOST = "localhost"
DEFAULT_PORT = "9101"
DEFAULT_USER = "admin"
DEFAULT_PASS = "bacula2026"
SERVICE_NAME = "bacula-mock"


def _load_env_file():
    """Load .env from project root if it exists."""
    script_dir = os.path.dirname(os.path.abspath(__file__))
    project_root = os.path.dirname(script_dir)
    env_path = os.path.join(project_root, ".env")
    if os.path.isfile(env_path):
        with open(env_path) as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    key, _, value = line.partition("=")
                    value = value.strip().strip('"').strip("'")
                    os.environ.setdefault(key.strip(), value)


_load_env_file()

# Determine protocol based on TLS setting
_TLS_ENABLED = os.environ.get("BACULA_MOCK_TLS", "false").lower() in ("true", "1", "yes")
_PROTOCOL = "https" if _TLS_ENABLED else "http"

BASE_URL = "{}://{}:{}".format(
    _PROTOCOL,
    os.environ.get("BACULA_MOCK_HOST", DEFAULT_HOST),
    os.environ.get("BACULA_MOCK_PORT", DEFAULT_PORT),
)
USERNAME = os.environ.get("BACULA_MOCK_USERNAME", DEFAULT_USER)
PASSWORD = os.environ.get("BACULA_MOCK_PASSWORD", DEFAULT_PASS)


# ---------------------------------------------------------------------------
# HTTP helpers (stdlib only)
# ---------------------------------------------------------------------------

# SSL context for self-signed certificates
if _TLS_ENABLED:
    import ssl as _ssl
    _ssl_ctx = _ssl.create_default_context()
    _ssl_ctx.check_hostname = False
    _ssl_ctx.verify_mode = _ssl.CERT_NONE
else:
    _ssl_ctx = None


def _auth_header():
    creds = base64.b64encode(f"{USERNAME}:{PASSWORD}".encode()).decode()
    return f"Basic {creds}"


def _get(path, params=None):
    url = BASE_URL + path
    if params:
        qs = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
        if qs:
            url += "?" + qs
    req = urllib.request.Request(url, headers={"Authorization": _auth_header()})
    try:
        with urllib.request.urlopen(req, timeout=10, context=_ssl_ctx) as resp:
            return json.loads(resp.read())
    except urllib.error.URLError as exc:
        _die(f"Cannot reach server at {BASE_URL}: {exc}")


def _post(path, body=None):
    url = BASE_URL + path
    data = json.dumps(body or {}).encode()
    req = urllib.request.Request(
        url, data=data,
        headers={"Authorization": _auth_header(), "Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=10, context=_ssl_ctx) as resp:
            return json.loads(resp.read())
    except urllib.error.URLError as exc:
        _die(f"Cannot reach server at {BASE_URL}: {exc}")


def _die(msg):
    print(f"Error: {msg}", file=sys.stderr)
    sys.exit(1)


def _fmt_bytes(b):
    for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
        if b < 1024:
            return f"{b:,.1f} {unit}"
        b /= 1024
    return f"{b:,.1f} PiB"


def _raw_or(data, formatter):
    """Print raw JSON if --raw flag is set, otherwise use formatter."""
    if RAW:
        json.dump(data, sys.stdout)
        print()
    else:
        formatter(data)


# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------

def _systemctl(action):
    """Run systemctl with sudo if needed and available."""
    import shutil
    cmd = ["systemctl", action, SERVICE_NAME]
    if os.geteuid() != 0:
        sudo = shutil.which("sudo")
        if sudo:
            cmd.insert(0, sudo)
        else:
            _die("Not running as root and sudo not found. Run as root or install sudo.")
    subprocess.run(cmd, check=True)


def _wait_for_server(timeout=15):
    """Wait until the server is accepting connections, return True if ready."""
    import time
    for i in range(timeout):
        try:
            req = urllib.request.Request(
                BASE_URL + "/mock/health",
                headers={"Authorization": _auth_header()},
            )
            with urllib.request.urlopen(req, timeout=2, context=_ssl_ctx) as resp:
                if resp.status == 200:
                    return True
        except Exception:
            pass
        time.sleep(1)
    return False


def _print_connection_info():
    """Print quick-start connection examples."""
    curl_k = "-k " if _TLS_ENABLED else ""
    print()
    print("Connect to the server:")
    print(f"  curl {curl_k}{BASE_URL}/mock/health")
    print(f"  curl {curl_k}-u {USERNAME}:{PASSWORD} {BASE_URL}/cat/Client")
    print(f"  curl {curl_k}-u {USERNAME}:{PASSWORD} {BASE_URL}/cat/JobTotals")
    print(f"  curl {curl_k}-u {USERNAME}:{PASSWORD} \"{BASE_URL}/cat/Job?limit=10\"")
    print()
    print("Or use the CLI:")
    print("  bacula-mock health")
    print("  bacula-mock clients")
    print("  bacula-mock jobs")
    print()


def cmd_start(_args):
    _systemctl("start")
    print(f"Service {SERVICE_NAME} starting...", end=" ", flush=True)
    if _wait_for_server():
        print("ready.")
        _print_connection_info()
    else:
        print("FAILED.")
        print()
        # Check if the service is actually running
        try:
            result = subprocess.run(
                ["systemctl", "is-active", SERVICE_NAME],
                capture_output=True, text=True,
            )
            state = result.stdout.strip()
        except Exception:
            state = "unknown"

        if state != "active":
            print(f"  Service state: {state}")
            print()
            print("  Troubleshooting:")
            print(f"    sudo systemctl status {SERVICE_NAME}")
            print(f"    sudo journalctl -u {SERVICE_NAME} -n 30 --no-pager")
            print()
            # Try to show the last few journal lines directly
            try:
                log_result = subprocess.run(
                    ["journalctl", "-u", SERVICE_NAME, "-n", "10", "--no-pager"],
                    capture_output=True, text=True,
                )
                if log_result.stdout.strip():
                    print("  Recent logs:")
                    for line in log_result.stdout.strip().splitlines():
                        print(f"    {line}")
                    print()
            except Exception:
                pass
        else:
            print(f"  Service is active but not responding on {BASE_URL}")
            print(f"  Check: sudo journalctl -u {SERVICE_NAME} -f")


def cmd_stop(_args):
    _systemctl("stop")
    print(f"Service {SERVICE_NAME} stopped.")


def cmd_restart(_args):
    _systemctl("restart")
    print(f"Service {SERVICE_NAME} restarting...", end=" ", flush=True)
    if _wait_for_server():
        print("ready.")
        _print_connection_info()
    else:
        print("FAILED.")
        print()
        try:
            result = subprocess.run(
                ["systemctl", "is-active", SERVICE_NAME],
                capture_output=True, text=True,
            )
            state = result.stdout.strip()
        except Exception:
            state = "unknown"

        if state != "active":
            print(f"  Service state: {state}")
            print()
            print("  Troubleshooting:")
            print(f"    sudo systemctl status {SERVICE_NAME}")
            print(f"    sudo journalctl -u {SERVICE_NAME} -n 30 --no-pager")
        else:
            print(f"  Service is active but not responding on {BASE_URL}")
            print(f"  Check: sudo journalctl -u {SERVICE_NAME} -f")


def cmd_init(args):
    clients = int(args[0]) if args else 100
    error_rate = float(args[1]) if len(args) > 1 else 0.25
    data = _post("/mock/initialize", {"clients": clients, "error_rate": error_rate})

    def fmt(d):
        info = d.get("data", {})
        print(f"Initialized: {info.get('clients_created', '?')} clients, "
              f"{info.get('jobs_created', '?')} jobs")

    _raw_or(data, fmt)


def cmd_reset(_args):
    data = _post("/mock/reset")

    def fmt(d):
        info = d.get("data", {})
        print(f"Reset complete. Deleted {info.get('deleted_jobs', '?')} jobs.")

    _raw_or(data, fmt)


def cmd_health(_args):
    data = _get("/mock/health")

    def fmt(d):
        print(f"Status:  {d.get('status', '?')}")
        print(f"Version: {d.get('version', '?')}")
        print(f"Clients: {d.get('clients', '?')}")
        print(f"Jobs:    {d.get('jobs', '?')}")

    _raw_or(data, fmt)


def cmd_token(_args):
    data = _post("/oauth/token", {"username": USERNAME, "password": PASSWORD})

    def fmt(d):
        print(f"Token:      {d.get('access_token', '?')}")
        print(f"Expires in: {d.get('expires_in', '?')}s")

    _raw_or(data, fmt)


def cmd_clients(args):
    limit = args[0] if args else "50"
    data = _get("/cat/Client", {"limit": limit})

    def fmt(d):
        clients = d.get("data", [])
        if not clients:
            print("No clients found.")
            return
        print(f"{'ID':>4s}  {'Name':<25s}  OS")
        print("-" * 65)
        for c in clients:
            print(f"{c['clientid']:4d}  {c['name']:<25s}  {c['uname']}")
        print(f"\n{len(clients)} client(s)")

    _raw_or(data, fmt)


def cmd_jobs(args):
    limit = args[0] if args else "20"
    data = _get("/cat/Job", {"limit": limit})

    def fmt(d):
        jobs = d.get("data", [])
        if not jobs:
            print("No jobs found.")
            return
        print(f"{'ID':>5s}  {'Status':>6s}  {'Level':>5s}  "
              f"{'Files':>10s}  {'Size':>10s}  {'Errors':>6s}  Name")
        print("-" * 85)
        for j in jobs:
            st = "OK" if j["jobstatus"] == "T" else j["jobstatus"]
            lvl = {"F": "Full", "I": "Incr", "D": "Diff"}.get(j["level"], j["level"])
            print(f"{j['jobid']:5d}  {st:>6s}  {lvl:>5s}  {j['jobfiles']:>10,}  "
                  f"{_fmt_bytes(j['jobbytes']):>10s}  {j['joberrors']:>6d}  {j['name']}")
        print(f"\n{len(jobs)} job(s)")

    _raw_or(data, fmt)


def cmd_jobs_failed(args):
    limit = args[0] if args else "50"
    data = _get("/cat/Job", {"jobstatus": "E", "limit": limit})

    def fmt(d):
        jobs = d.get("data", [])
        if not jobs:
            print("No failed jobs.")
            return
        print(f"{'ID':>5s}  {'Errors':>6s}  {'Files':>10s}  Name")
        print("-" * 65)
        for j in jobs:
            print(f"{j['jobid']:5d}  {j['joberrors']:>6d}  {j['jobfiles']:>10,}  {j['name']}")
        print(f"\n{len(jobs)} failed job(s)")

    _raw_or(data, fmt)


def cmd_jobs_for(args):
    if not args:
        _die("Usage: bacula-mock jobs-for CLIENTID [LIMIT]")
    clientid = args[0]
    limit = args[1] if len(args) > 1 else "50"
    data = _get("/cat/Job", {"clientid": clientid, "limit": limit})

    def fmt(d):
        jobs = d.get("data", [])
        if not jobs:
            print(f"No jobs for client {clientid}.")
            return
        for j in jobs:
            st = "OK" if j["jobstatus"] == "T" else "ERROR"
            lvl = {"F": "Full", "I": "Incr", "D": "Diff"}.get(j["level"], "?")
            print(f"Job {j['jobid']:4d}  {lvl:5s}  {st:6s}  "
                  f"{j['jobfiles']:>8,} files  {j['jobbytes'] / 1024**2:>10,.0f} MB")

    _raw_or(data, fmt)


def cmd_joblog(args):
    if not args:
        _die("Usage: bacula-mock joblog JOBID")
    data = _get("/cat/JobLog", {"jobid": args[0]})

    def fmt(d):
        logs = d.get("data", [])
        if not logs:
            print(f"No logs for Job {args[0]}.")
            return
        for entry in logs:
            print(f"{entry['time']}  {entry['logtext']}")

    _raw_or(data, fmt)


def cmd_jobtotals(_args):
    data = _get("/cat/JobTotals")

    def fmt(d):
        t = d["data"][0]
        print(f"Jobs:   {t['jobs']:>10,}")
        print(f"Files:  {t['files']:>10,}")
        print(f"Bytes:  {_fmt_bytes(t['bytes']):>10s}")
        print(f"Errors: {t['joberrors']:>10,}")

    _raw_or(data, fmt)


def cmd_pools(_args):
    data = _get("/cat/Pool")

    def fmt(d):
        pools = d.get("data", [])
        if not pools:
            print("No pools found.")
            return
        print(f"{'ID':>4s}  {'Name':<20s}  {'Vols':>5s}  Type")
        print("-" * 45)
        for p in pools:
            print(f"{p['poolid']:4d}  {p['name']:<20s}  {p['numvols']:>5d}  {p['pooltype']}")

    _raw_or(data, fmt)


def cmd_dir_status(_args):
    data = _get("/status/director")

    def fmt(d):
        info = d["data"][0]
        print(f"Director: {info['director-daemon']} v{info['version']}")
        print(f"Jobs run:     {info['jobs_run']}")
        print(f"Jobs running: {info['jobs_running']}")
        terminated = info.get("jobs", {}).get("terminated", [])
        if terminated:
            print(f"\nLast terminated jobs:")
            for j in terminated[:10]:
                status = "OK" if j["jobstatus"] == "T" else "FAIL"
                print(f"  {status:<5s} Job {j['jobid']:4d}  "
                      f"{j['name']:<35s}  {j['jobfiles']:>8,} files")

    _raw_or(data, fmt)


def cmd_client(args):
    if not args:
        _die("Usage: bacula-mock client NAME")
    data = _get("/cat/Client", {"name": args[0]})

    def fmt(d):
        clients = d.get("data", [])
        if not clients:
            print(f"Client '{args[0]}' not found.")
            return
        c = clients[0]
        print(f"ID:             {c['clientid']}")
        print(f"Name:           {c['name']}")
        print(f"OS:             {c['uname']}")
        print(f"AutoPrune:      {c['autoprune']}")
        print(f"FileRetention:  {c['fileretention']}")
        print(f"JobRetention:   {c['jobretention']}")

    _raw_or(data, fmt)


def cmd_client_status(args):
    if not args:
        _die("Usage: bacula-mock client-status NAME")
    data = _post(f"/status/client/?name={args[0]}")

    def fmt(d):
        info = d["data"][0]
        print(f"Client:       {info['client-daemon']}")
        print(f"Version:      {info['version']}")
        print(f"Jobs run:     {info['jobs_run']}")
        print(f"Jobs running: {info['jobs_running']}")

    _raw_or(data, fmt)


def cmd_config_clients(_args):
    data = _get("/res/director/", {"resource": "Client"})

    def fmt(d):
        for c in d:
            print(f"{c['Name']:<25s}  {c['Address']}:{c['FDPort']}")

    _raw_or(data, fmt)


def cmd_export_csv(_args):
    data = _get("/cat/Job", {"limit": "10000"})
    jobs = data.get("data", [])
    if not jobs:
        _die("No jobs to export.")
    writer = csv.DictWriter(sys.stdout, fieldnames=jobs[0].keys())
    writer.writeheader()
    writer.writerows(jobs)


def cmd_export_errors(_args):
    data = _get("/cat/Job", {"jobstatus": "E", "limit": "10000"})
    json.dump(data, sys.stdout, indent=2)
    print()


def cmd_smoke_test(_args):
    passed = 0
    failed = 0

    def check(name, method, path, body=None):
        nonlocal passed, failed
        try:
            if method == "GET":
                _get(path)
            else:
                _post(path, body)
            print(f"  OK   {name} (200)")
            passed += 1
        except SystemExit:
            print(f"  FAIL {name}")
            failed += 1

    print("=== Bacula Mock API Smoke Test ===\n")

    print("[Auth]")
    check("OAuth2 Token", "POST", "/oauth/token",
          {"username": USERNAME, "password": PASSWORD})

    print("\n[Mock]")
    check("Health", "GET", "/mock/health")
    check("Initialize", "POST", "/mock/initialize",
          {"clients": 10, "error_rate": 0.25})

    print("\n[Catalog]")
    check("/cat/Client", "GET", "/cat/Client?limit=5")
    check("/cat/Client?clientid=1", "GET", "/cat/Client?clientid=1")
    check("/cat/Job", "GET", "/cat/Job?limit=5")
    check("/cat/Job?jobstatus=E", "GET", "/cat/Job?jobstatus=E&limit=5")
    check("/cat/Pool", "GET", "/cat/Pool")
    check("/cat/Storage", "GET", "/cat/Storage")
    check("/cat/JobTotals", "GET", "/cat/JobTotals")
    check("/cat/JobLog?jobid=1", "GET", "/cat/JobLog?jobid=1")

    print("\n[Commands]")
    check("/cmd/list?jobs", "GET", "/cmd/list?jobs&limit=3")
    check("/cmd/list?clients", "GET", "/cmd/list?clients&limit=3")

    print("\n[Status]")
    check("/status/director", "GET", "/status/director")
    check("/status/client", "POST", "/status/client/?name=mail-server-001")

    print("\n[Resources]")
    check("/res/director", "GET", "/res/director/")
    check("/res/director?resource=Client", "GET", "/res/director/?resource=Client")

    print(f"\n=== Result: {passed} passed, {failed} failed ===")
    sys.exit(1 if failed else 0)


# ---------------------------------------------------------------------------
# Command dispatch
# ---------------------------------------------------------------------------

COMMANDS = {
    "start":          cmd_start,
    "stop":           cmd_stop,
    "restart":        cmd_restart,
    "init":           cmd_init,
    "reset":          cmd_reset,
    "health":         cmd_health,
    "token":          cmd_token,
    "clients":        cmd_clients,
    "jobs":           cmd_jobs,
    "jobs-failed":    cmd_jobs_failed,
    "jobs-for":       cmd_jobs_for,
    "joblog":         cmd_joblog,
    "jobtotals":      cmd_jobtotals,
    "pools":          cmd_pools,
    "dir-status":     cmd_dir_status,
    "client":         cmd_client,
    "client-status":  cmd_client_status,
    "config-clients": cmd_config_clients,
    "export-csv":     cmd_export_csv,
    "export-errors":  cmd_export_errors,
    "smoke-test":     cmd_smoke_test,
}

USAGE = """\
Usage: bacula-mock COMMAND [ARGS...] [--raw]

Server:
  start                         Start server (systemd)
  stop                          Stop server (systemd)
  restart                       Restart server (systemd)

Data:
  init [CLIENTS] [ERROR_RATE]   Generate mock data (default: 100, 0.25)
  reset                         Delete all mock data

Query:
  clients [LIMIT]               List clients
  client NAME                   Show client detail
  client-status NAME            Show client FD status
  jobs [LIMIT]                  List jobs
  jobs-failed [LIMIT]           List failed jobs
  jobs-for CLIENTID [LIMIT]     List jobs for a client
  joblog JOBID                  Show job logs
  jobtotals                     Show job totals
  pools                         List pools
  dir-status                    Show director status
  config-clients                Show client resource configs

Auth:
  token                         Request OAuth2 token

Export:
  export-csv                    Export all jobs as CSV
  export-errors                 Export failed jobs as JSON

Test:
  health                        Server health check
  smoke-test                    Test all API endpoints

Options:
  --raw                         Output unformatted JSON (for piping)
"""


def main():
    global RAW
    argv = [a for a in sys.argv[1:] if a != "--raw"]
    RAW = "--raw" in sys.argv

    if not argv or argv[0] in ("-h", "--help"):
        print(USAGE)
        sys.exit(0)

    command = argv[0]
    args = argv[1:]

    if command not in COMMANDS:
        print(f"Unknown command: {command}", file=sys.stderr)
        print(USAGE, file=sys.stderr)
        sys.exit(1)

    COMMANDS[command](args)


RAW = False

if __name__ == "__main__":
    main()

