#!/usr/bin/env python3
"""CLI for controlling a Crank CI server, locally or via SSH."""

import argparse
import difflib
import json
import os
import re
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

DEFAULT_CRANK_HOME = os.path.expanduser("~/.local/share/crank")

# Crank result codes
RESULTS = {0: "unknown", 1: "queued", 2: "running", 3: "aborted", 4: "failed", 5: "success"}


def _get_config(args):
    """Resolve crank home, jobs dir, db path, and SSH host."""
    host = args.remote or os.environ.get("CRANK_REMOTE")
    home = args.crank_home or os.environ.get("CRANK_HOME", DEFAULT_CRANK_HOME)
    return {
        "host": host,
        "home": home,
        "jobs_dir": f"{home}/cfg/jobs",
        "db": f"{home}/crank.sqlite",
    }


def _run_cmd(cfg, cmd, check=True):
    """Run a command locally or via SSH."""
    if cfg["host"]:
        full_cmd = ["ssh", cfg["host"], cmd]
    else:
        full_cmd = ["sh", "-c", cmd]
    result = subprocess.run(full_cmd, capture_output=True, text=True)
    if check and result.returncode != 0:
        stderr = result.stderr.strip()
        if stderr:
            print(stderr, file=sys.stderr)
        sys.exit(1)
    return result


def _run_python(cfg, script):
    """Run a Python snippet and return parsed JSON."""
    result = _run_cmd(cfg, f"python3 -c {_shell_quote(script)}")
    try:
        return json.loads(result.stdout)
    except json.JSONDecodeError:
        msg = "Failed to parse output from remote Python command."
        if result.stderr.strip():
            msg += f"\nRemote stderr:\n{result.stderr.strip()}"
        elif not result.stdout.strip():
            msg += " Command produced no output."
        else:
            msg += f"\nRaw output:\n{result.stdout.strip()}"
        print(msg, file=sys.stderr)
        sys.exit(1)


def _shell_quote(s):
    """Single-quote a string for shell."""
    return "'" + s.replace("'", "'\"'\"'") + "'"


def _validate_job_name(job):
    """Reject job names that don't match safe characters."""
    if not re.match(r'^[a-zA-Z0-9_-]+$', job):
        print(f"Invalid job name: {job!r} — only alphanumerics, hyphens, and underscores are allowed.", file=sys.stderr)
        sys.exit(1)


def _format_ts(ts):
    """Format a unix timestamp."""
    if not ts:
        return "-"
    return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")


def _result_str(code):
    return RESULTS.get(code, f"unknown({code})")


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

def cmd_list(args):
    """List all jobs with their last state."""
    cfg = _get_config(args)
    data = _run_python(cfg, """
import json, sqlite3
from pathlib import Path

jobs_dir = Path({jobs_dir})
state_dir = Path({home})
db = sqlite3.connect({db})
cur = db.cursor()

result = []
for job in sorted(jobs_dir.glob("*.run")):
    name = job.stem
    state_file = state_dir / f"{{name}}.last"
    state = state_file.read_text().strip() if state_file.exists() else None

    cur.execute(
        "SELECT number, completedAt, result FROM builds WHERE name=? ORDER BY number DESC LIMIT 1",
        (name,),
    )
    row = cur.fetchone()
    last_run = None
    if row:
        last_run = {{"number": row[0], "completed": row[1], "result": row[2]}}

    result.append({{"name": name, "state": state, "last_run": last_run}})

db.close()
print(json.dumps(result))
""".format(jobs_dir=repr(cfg["jobs_dir"]), home=repr(cfg["home"]), db=repr(cfg["db"])))

    if getattr(args, "json", False):
        print(json.dumps(data, indent=2))
        return

    print(f"{'Job':<35} {'Last Run':<8} {'Result':<10} {'Completed':<26} {'State'}")
    print("-" * 100)
    for job in data:
        run = job["last_run"]
        num = str(run["number"]) if run else "-"
        result = _result_str(run["result"]) if run else "-"
        completed = _format_ts(run["completed"]) if run else "-"
        state = job["state"] or "-"
        print(f"{job['name']:<35} {num:<8} {result:<10} {completed:<26} {state}")


def cmd_status(args):
    """Show currently running and queued jobs."""
    cfg = _get_config(args)
    data = _run_python(cfg, """
import json, sqlite3, time
db = sqlite3.connect({db})
cur = db.cursor()
cur.execute(
    "SELECT name, number, result, startedAt, queuedAt, reason FROM builds "
    "WHERE result IN (1, 2) ORDER BY number ASC"
)
now = time.time()
rows = []
for r in cur.fetchall():
    started = r[3]
    duration = None
    if r[2] == 2 and started:
        duration = int(now - started)
    rows.append({{"name": r[0], "number": r[1], "result": r[2],
                  "started": started, "queued": r[4], "reason": r[5],
                  "duration": duration}})
db.close()
print(json.dumps(rows))
""".format(db=repr(cfg["db"])))

    if not data:
        print("No running or queued jobs.")
        return

    if getattr(args, "json", False):
        print(json.dumps(data, indent=2))
        return

    print(f"{'Job':<30} {'#':<8} {'Status':<10} {'Started':<26} {'Duration':<14} {'Trigger'}")
    print("-" * 105)
    for row in data:
        status = _result_str(row["result"])
        started = _format_ts(row["started"])
        if row["result"] == 2 and row["duration"] is not None:
            duration = f"{row['duration']}s"
        elif row["result"] == 1:
            duration = "queued"
        else:
            duration = "-"
        reason = row["reason"] or "-"
        print(f"{row['name']:<30} {row['number']:<8} {status:<10} {started:<26} {duration:<14} {reason}")


def cmd_trigger(args):
    """Trigger a job."""
    _validate_job_name(args.job)
    cfg = _get_config(args)
    env_parts = " ".join(_shell_quote(e) for e in args.env) if args.env else ""
    mode = "queue" if args.queue else "run"
    cmd = f"crankc {mode} {_shell_quote(args.job)} {env_parts}".strip()
    print(f"$ {cmd}")
    result = _run_cmd(cfg, cmd, check=False)
    if result.stdout.strip():
        print(result.stdout.strip())
    if result.returncode != 0:
        if result.stderr.strip():
            print(result.stderr.strip(), file=sys.stderr)
        sys.exit(result.returncode)


def cmd_cat(args):
    """Print a job script."""
    _validate_job_name(args.job)
    cfg = _get_config(args)
    path = f"{cfg['jobs_dir']}/{args.job}.run"
    result = _run_cmd(cfg, f"cat {_shell_quote(path)}", check=False)
    if result.returncode != 0:
        print(f"Job not found: {args.job}", file=sys.stderr)
        sys.exit(1)
    print(result.stdout, end="")


def cmd_logs(args):
    """Show output from a job run."""
    _validate_job_name(args.job)
    if args.last < 1:
        print("Error: --last must be at least 1", file=sys.stderr)
        sys.exit(1)
    cfg = _get_config(args)
    run_filter = ""
    if args.run:
        try:
            run_number = int(args.run)
        except ValueError:
            print(f"Error: --run value must be a number, got: {args.run}", file=sys.stderr)
            sys.exit(1)
        run_filter = f"AND number = {run_number}"

    data = _run_python(cfg, """
import json, sqlite3
db = sqlite3.connect({db})
cur = db.cursor()
cur.execute(
    "SELECT number, startedAt, completedAt, result, output FROM builds "
    "WHERE name=? {run_filter} ORDER BY number DESC LIMIT {limit}",
    ({job},),
)
rows = []
for r in cur.fetchall():
    rows.append({{"number": r[0], "started": r[1], "completed": r[2], "result": r[3], "output": r[4]}})
db.close()
print(json.dumps(rows))
""".format(db=repr(cfg["db"]), job=repr(args.job), run_filter=run_filter, limit=1 if args.run else args.last))

    if not data:
        print(f"No runs found for: {args.job}")
        return

    if getattr(args, "json", False):
        print(json.dumps(data, indent=2))
        return

    for run in data:
        print(f"=== {args.job} #{run['number']} ({_result_str(run['result'])}) ===")
        print(f"Started:   {_format_ts(run['started'])}")
        print(f"Completed: {_format_ts(run['completed'])}")
        print()
        print(run["output"] or "(no output)")
        if len(data) > 1:
            print()


def cmd_history(args):
    """Show recent runs with pass/fail."""
    _validate_job_name(args.job)
    if args.last < 1:
        print("Error: --last must be at least 1", file=sys.stderr)
        sys.exit(1)
    cfg = _get_config(args)
    data = _run_python(cfg, """
import json, sqlite3
db = sqlite3.connect({db})
cur = db.cursor()
cur.execute(
    "SELECT number, startedAt, completedAt, result, reason, parentJob, parentBuild FROM builds "
    "WHERE name=? ORDER BY number DESC LIMIT {limit}",
    ({job},),
)
rows = []
for r in cur.fetchall():
    rows.append({{"number": r[0], "started": r[1], "completed": r[2], "result": r[3], "reason": r[4], "parent_job": r[5] or "", "parent_build": r[6] or 0}})
db.close()
print(json.dumps(rows))
""".format(db=repr(cfg["db"]), job=repr(args.job), limit=args.last))

    if not data:
        print(f"No runs found for: {args.job}")
        return

    if getattr(args, "json", False):
        print(json.dumps(data, indent=2))
        return

    print(f"{'#':<8} {'Result':<10} {'Started':<26} {'Duration':<12} {'Trigger':<20} {'Parent'}")
    print("-" * 100)
    for run in data:
        started = run["started"] or 0
        completed = run["completed"] or 0
        duration = f"{int(completed - started)}s" if started and completed else "-"
        reason = run["reason"] or "-"
        parent = f"{run['parent_job']}#{run['parent_build']}" if run.get('parent_job') else "-"
        print(f"{run['number']:<8} {_result_str(run['result']):<10} {_format_ts(started):<26} {duration:<12} {reason:<20} {parent}")


def cmd_stats(args):
    """Show aggregate statistics for a job."""
    _validate_job_name(args.job)
    if args.last < 1:
        print("Error: --last must be at least 1", file=sys.stderr)
        sys.exit(1)
    cfg = _get_config(args)
    data = _run_python(cfg, """
import json, sqlite3
db = sqlite3.connect({db})
cur = db.cursor()
cur.execute(
    "SELECT result, startedAt, completedAt FROM builds "
    "WHERE name=? AND result IN (3, 4, 5) ORDER BY number DESC LIMIT {limit}",
    ({job},),
)
rows = cur.fetchall()
total = len(rows)
success = sum(1 for r in rows if r[0] == 5)
failed = sum(1 for r in rows if r[0] == 4)
aborted = sum(1 for r in rows if r[0] == 3)
durations = []
for r in rows:
    if r[1] and r[2]:
        durations.append(r[2] - r[1])
avg_dur = sum(durations) / len(durations) if durations else 0
min_dur = min(durations) if durations else 0
max_dur = max(durations) if durations else 0
success_rate = (success / total * 100) if total > 0 else 0
db.close()
print(json.dumps({{"total": total, "success": success, "failed": failed,
                   "aborted": aborted, "success_rate": round(success_rate, 1),
                   "avg_duration": round(avg_dur, 1),
                   "min_duration": round(min_dur, 1),
                   "max_duration": round(max_dur, 1)}}))
""".format(db=repr(cfg["db"]), job=repr(args.job), limit=args.last))

    if data["total"] == 0:
        print(f"No completed runs found for: {args.job}")
        return

    if getattr(args, "json", False):
        print(json.dumps(data, indent=2))
        return

    print(f"Statistics for {args.job} (last {args.last} completed builds):")
    print()
    print(f"  Total runs:    {data['total']}")
    print(f"  Success:       {data['success']}")
    print(f"  Failed:        {data['failed']}")
    print(f"  Aborted:       {data['aborted']}")
    print(f"  Success rate:  {data['success_rate']}%")
    print()
    print(f"  Avg duration:  {data['avg_duration']}s")
    print(f"  Min duration:  {data['min_duration']}s")
    print(f"  Max duration:  {data['max_duration']}s")


def cmd_wait(args):
    """Block until a running/queued build completes."""
    _validate_job_name(args.job)
    cfg = _get_config(args)

    run_filter = ""
    if args.run:
        try:
            run_number = int(args.run)
        except ValueError:
            print(f"Error: --run value must be a number, got: {args.run}", file=sys.stderr)
            sys.exit(1)
        run_filter = f"AND number = {run_number}"

    start_time = time.time()
    while True:
        data = _run_python(cfg, """
import json, sqlite3
db = sqlite3.connect({db})
cur = db.cursor()
cur.execute(
    "SELECT number, result, startedAt, completedAt FROM builds "
    "WHERE name=? {run_filter} ORDER BY number DESC LIMIT 1",
    ({job},),
)
row = cur.fetchone()
result = None
if row:
    result = {{"number": row[0], "result": row[1], "started": row[2], "completed": row[3]}}
db.close()
print(json.dumps(result))
""".format(db=repr(cfg["db"]), job=repr(args.job), run_filter=run_filter))

        if data is None:
            print(f"No builds found for: {args.job}", file=sys.stderr)
            sys.exit(1)

        elapsed = int(time.time() - start_time)
        result_code = data["result"]

        if result_code not in (1, 2):
            result_label = _result_str(result_code)
            prefix = "\r" if sys.stdout.isatty() else ""
            print(f"{prefix}{args.job} #{data['number']} completed: {result_label} (waited {elapsed}s)")
            if result_code == 5:
                sys.exit(0)
            else:
                sys.exit(1)

        timeout = getattr(args, "timeout", None)
        if timeout is not None and elapsed >= timeout:
            prefix = "\r" if sys.stdout.isatty() else ""
            print(f"{prefix}Timeout after {elapsed}s waiting for {args.job} #{data['number']}", file=sys.stderr)
            sys.exit(2)

        status = _result_str(result_code)
        if sys.stdout.isatty():
            print(f"\rWaiting for {args.job} #{data['number']}... {status} ({elapsed}s elapsed)", end="", flush=True)
        time.sleep(2)


def cmd_abort(args):
    """Abort a running or queued build."""
    _validate_job_name(args.job)
    cfg = _get_config(args)

    if args.run:
        try:
            run_number = int(args.run)
        except ValueError:
            print(f"Error: --run value must be a number, got: {args.run}", file=sys.stderr)
            sys.exit(1)
    else:
        data = _run_python(cfg, """
import json, sqlite3
db = sqlite3.connect({db})
cur = db.cursor()
cur.execute(
    "SELECT number, result FROM builds "
    "WHERE name=? AND result IN (1, 2) ORDER BY number DESC LIMIT 1",
    ({job},),
)
row = cur.fetchone()
db.close()
print(json.dumps({{"number": row[0], "result": row[1]}} if row else None))
""".format(db=repr(cfg["db"]), job=repr(args.job)))

        if data is None:
            print(f"No running or queued build found for: {args.job}", file=sys.stderr)
            sys.exit(1)
        run_number = data["number"]

    print(f"Aborting {args.job} #{run_number}")
    cmd = f"crankc abort {_shell_quote(args.job)} {_shell_quote(str(run_number))}"
    result = _run_cmd(cfg, cmd, check=False)
    if result.stdout.strip():
        print(result.stdout.strip())
    if result.returncode != 0:
        if result.stderr.strip():
            print(result.stderr.strip(), file=sys.stderr)
        sys.exit(result.returncode)


def cmd_retry(args):
    """Retry the last failed or aborted build of a job."""
    _validate_job_name(args.job)
    cfg = _get_config(args)

    if args.run:
        try:
            run_number = int(args.run)
        except ValueError:
            print(f"Error: --run value must be a number, got: {args.run}", file=sys.stderr)
            sys.exit(1)
        data = _run_python(cfg, """
import json, sqlite3
db = sqlite3.connect({db})
cur = db.cursor()
cur.execute(
    "SELECT number, result, params FROM builds "
    "WHERE name=? AND number=? AND result IN (3, 4) LIMIT 1",
    ({job}, {run_number}),
)
row = cur.fetchone()
db.close()
print(json.dumps({{"number": row[0], "result": row[1], "params": row[2] or ""}} if row else None))
""".format(db=repr(cfg["db"]), job=repr(args.job), run_number=repr(run_number)))

        if data is None:
            print(f"No failed or aborted build #{run_number} found for: {args.job}", file=sys.stderr)
            sys.exit(1)
    else:
        data = _run_python(cfg, """
import json, sqlite3
db = sqlite3.connect({db})
cur = db.cursor()
cur.execute(
    "SELECT number, result, params FROM builds "
    "WHERE name=? AND result IN (3, 4) ORDER BY number DESC LIMIT 1",
    ({job},),
)
row = cur.fetchone()
db.close()
print(json.dumps({{"number": row[0], "result": row[1], "params": row[2] or ""}} if row else None))
""".format(db=repr(cfg["db"]), job=repr(args.job)))

        if data is None:
            print(f"No failed or aborted build found for: {args.job}", file=sys.stderr)
            sys.exit(1)
        run_number = data["number"]

    print(f"Retrying {args.job} #{run_number} ({_result_str(data['result'])})")
    env_parts = ""
    if data.get("params"):
        try:
            params = json.loads(data["params"])
            if isinstance(params, dict):
                env_parts = " ".join(_shell_quote(f"{k}={v}") for k, v in params.items())
        except (json.JSONDecodeError, TypeError, AttributeError):
            pass
    cmd = f"crankc run {_shell_quote(args.job)} {env_parts}".strip()
    result = _run_cmd(cfg, cmd, check=False)
    if result.stdout.strip():
        print(result.stdout.strip())
    if result.returncode != 0:
        if result.stderr.strip():
            print(result.stderr.strip(), file=sys.stderr)
        sys.exit(result.returncode)


def cmd_tail(args):
    """Stream live output from a running build."""
    _validate_job_name(args.job)
    cfg = _get_config(args)

    if args.run:
        try:
            run_number = int(args.run)
        except ValueError:
            print(f"Error: --run value must be a number, got: {args.run}", file=sys.stderr)
            sys.exit(1)
    else:
        data = _run_python(cfg, """
import json, sqlite3
db = sqlite3.connect({db})
cur = db.cursor()
cur.execute(
    "SELECT number FROM builds "
    "WHERE name=? AND result=2 ORDER BY number DESC LIMIT 1",
    ({job},),
)
row = cur.fetchone()
db.close()
print(json.dumps({{"number": row[0]}} if row else None))
""".format(db=repr(cfg["db"]), job=repr(args.job)))

        if data is None:
            print(f"No running build found for: {args.job}", file=sys.stderr)
            sys.exit(1)
        run_number = data["number"]

    log_path = f"{cfg['home']}/run/{args.job}/{run_number}/log"
    print(f"Tailing {args.job} #{run_number} — {log_path}")

    # Wait for log file to appear (daemon streams output to file)
    check = _run_cmd(cfg, f"test -f {_shell_quote(log_path)}", check=False)
    if check.returncode != 0:
        print(f"Log file not yet available: {log_path}", file=sys.stderr)
        print("Waiting for log file to appear...", file=sys.stderr)
        tail_deadline = time.time() + 120  # 2 minute timeout
        while time.time() < tail_deadline:
            time.sleep(1)
            check = _run_cmd(cfg, f"test -f {_shell_quote(log_path)}", check=False)
            if check.returncode == 0:
                break
            # Check if the build has completed without producing a log file
            build_check = _run_python(cfg, """
import json, sqlite3
db = sqlite3.connect({db})
row = db.execute("SELECT result FROM builds WHERE name=? AND number=?", ({job}, {run})).fetchone()
db.close()
print(json.dumps(row[0] if row else None))
""".format(db=repr(cfg["db"]), job=repr(args.job), run=repr(run_number)))
            if build_check is not None and build_check not in (1, 2):
                print(f"Build completed without producing a log file.", file=sys.stderr)
                sys.exit(1)
        else:
            print(f"Timed out waiting for log file: {log_path}", file=sys.stderr)
            sys.exit(1)

    if cfg["host"]:
        os.execvp("ssh", ["ssh", "-t", cfg["host"], f"tail -f {_shell_quote(log_path)}"])
    else:
        os.execvp("tail", ["tail", "-f", log_path])


def cmd_edit(args):
    """Open a job script in $EDITOR."""
    _validate_job_name(args.job)
    cfg = _get_config(args)
    job_path = f"{cfg['jobs_dir']}/{args.job}.run"
    if cfg["host"]:
        os.execvp("ssh", ["ssh", "-t", cfg["host"], f"$EDITOR {_shell_quote(job_path)}"])
    else:
        editor = os.environ.get("EDITOR", "vi")
        os.execvp(editor, [editor, job_path])


def cmd_create(args):
    """Scaffold a new job script."""
    _validate_job_name(args.job)
    cfg = _get_config(args)
    job_path = f"{cfg['jobs_dir']}/{args.job}.run"

    if args.template:
        _validate_job_name(args.template)
        template_path = f"{cfg['jobs_dir']}/{args.template}.run"
        result = _run_cmd(cfg, f"cat {_shell_quote(template_path)}", check=False)
        if result.returncode != 0:
            print(f"Template job not found: {args.template}", file=sys.stderr)
            sys.exit(1)
        content = result.stdout
    else:
        content = (
            "#!/bin/bash\n"
            "set -euo pipefail\n"
            f"# Crank job: {args.job}\n"
            "\n"
        )

    exists = _run_cmd(cfg, f"test -f {_shell_quote(job_path)}", check=False)
    if exists.returncode == 0:
        print(f"Job already exists: {job_path}", file=sys.stderr)
        sys.exit(1)

    # Use printf to avoid heredoc delimiter collision
    _run_cmd(cfg, f"printf '%s' {_shell_quote(content)} | tee {_shell_quote(job_path)} > /dev/null")
    _run_cmd(cfg, f"chmod +x {_shell_quote(job_path)}")
    print(f"Created: {job_path}")

    if args.edit:
        if cfg["host"]:
            os.execvp("ssh", ["ssh", "-t", cfg["host"], f"$EDITOR {_shell_quote(job_path)}"])
        else:
            editor = os.environ.get("EDITOR", "vi")
            os.execvp(editor, [editor, job_path])


def cmd_delete(args):
    """Delete a job script."""
    _validate_job_name(args.job)
    cfg = _get_config(args)
    job_path = f"{cfg['jobs_dir']}/{args.job}.run"

    exists = _run_cmd(cfg, f"test -f {_shell_quote(job_path)}", check=False)
    if exists.returncode != 0:
        print(f"Job not found: {args.job}", file=sys.stderr)
        sys.exit(1)

    if not args.yes:
        print(f"Would delete: {job_path}")
        print("Pass --yes / -y to confirm.")
        return

    _run_cmd(cfg, f"rm {_shell_quote(job_path)}")
    print(f"Deleted: {job_path}")


def cmd_disable(args):
    """Disable a job by renaming .run to .run.disabled."""
    _validate_job_name(args.job)
    cfg = _get_config(args)
    job_path = f"{cfg['jobs_dir']}/{args.job}.run"
    disabled_path = f"{cfg['jobs_dir']}/{args.job}.run.disabled"

    exists = _run_cmd(cfg, f"test -f {_shell_quote(job_path)}", check=False)
    if exists.returncode != 0:
        print(f"Job not found: {args.job}", file=sys.stderr)
        sys.exit(1)

    _run_cmd(cfg, f"mv {_shell_quote(job_path)} {_shell_quote(disabled_path)}")
    print(f"Disabled: {args.job}")


def cmd_enable(args):
    """Enable a disabled job by renaming .run.disabled back to .run."""
    _validate_job_name(args.job)
    cfg = _get_config(args)
    disabled_path = f"{cfg['jobs_dir']}/{args.job}.run.disabled"
    job_path = f"{cfg['jobs_dir']}/{args.job}.run"

    exists = _run_cmd(cfg, f"test -f {_shell_quote(disabled_path)}", check=False)
    if exists.returncode != 0:
        print(f"Disabled job not found: {args.job}", file=sys.stderr)
        sys.exit(1)

    _run_cmd(cfg, f"mv {_shell_quote(disabled_path)} {_shell_quote(job_path)}")
    print(f"Enabled: {args.job}")


def cmd_config(args):
    """View or edit a Crank config file for a job."""
    _validate_job_name(args.job)
    cfg = _get_config(args)

    extensions = {"conf": ".conf", "env": ".env", "before": ".before", "after": ".after"}
    ext = extensions.get(args.type)
    if ext is None:
        print(f"Unknown config type: {args.type!r} — must be one of: conf, env, before, after", file=sys.stderr)
        sys.exit(1)

    config_path = f"{cfg['jobs_dir']}/{args.job}{ext}"

    if args.edit:
        if cfg["host"]:
            os.execvp("ssh", ["ssh", "-t", cfg["host"], f"$EDITOR {_shell_quote(config_path)}"])
        else:
            editor = os.environ.get("EDITOR", "vi")
            os.execvp(editor, [editor, config_path])
    else:
        result = _run_cmd(cfg, f"cat {_shell_quote(config_path)}", check=False)
        if result.returncode != 0:
            print(f"Config not found: {config_path}", file=sys.stderr)
            sys.exit(1)
        print(result.stdout, end="")


def cmd_export(args):
    """Backup all job scripts and configs as a tar archive."""
    cfg = _get_config(args)
    output = args.output or f"crank-backup-{datetime.now().strftime('%Y%m%d')}.tar.gz"
    cfg_dir = f"{cfg['home']}/cfg"

    tar_cmd = f"tar czf - -C {_shell_quote(cfg_dir)} ."

    if cfg["host"]:
        full_cmd = ["ssh", cfg["host"], tar_cmd]
    else:
        full_cmd = ["sh", "-c", tar_cmd]

    with open(output, "wb") as f:
        result = subprocess.run(full_cmd, stdout=f, stderr=subprocess.PIPE)

    if result.returncode != 0:
        stderr = result.stderr.decode().strip() if result.stderr else ""
        if stderr:
            print(stderr, file=sys.stderr)
        if os.path.exists(output):
            os.remove(output)
        sys.exit(1)

    size = os.path.getsize(output)
    print(f"Exported to: {output} ({size} bytes)")


def cmd_init(args):
    """Initialize Crank CI on a host (user-level, no root required)."""
    cfg = _get_config(args)
    home = cfg["home"]

    # Create directory structure
    for d in ["cfg/jobs", "cfg/contexts", "cfg/scripts", "run"]:
        _run_cmd(cfg, f"mkdir -p {_shell_quote(home)}/{d}")

    # Resolve remote home directory for systemd paths (needs absolute paths)
    remote_home = None
    if cfg["host"]:
        remote_home = _run_cmd(cfg, "echo $HOME", check=False).stdout.strip() or "/root"

    # Verify crankd is in PATH
    crankd_check = _run_cmd(cfg, "command -v crankd", check=False)
    if crankd_check.stdout.strip():
        crankd_path = crankd_check.stdout.strip()
    elif remote_home:
        crankd_path = f"{remote_home}/.local/bin/crankd"
    else:
        crankd_path = os.path.expanduser("~/.local/bin/crankd")
    if crankd_check.returncode != 0:
        print("Warning: crankd not found in PATH.", file=sys.stderr)
        print("Install the crank package (e.g., pip install crank-ci)", file=sys.stderr)

    # Write config file if executors specified
    conf_path = f"{home}/crank.conf"
    if args.executors is not None:
        conf_content = f"CRANK_NUM_EXECUTORS={args.executors}"
        _run_cmd(cfg, f"grep -v '^CRANK_NUM_EXECUTORS=' {_shell_quote(conf_path)} 2>/dev/null > {_shell_quote(conf_path)}.tmp || true; echo {_shell_quote(conf_content)} >> {_shell_quote(conf_path)}.tmp; mv {_shell_quote(conf_path)}.tmp {_shell_quote(conf_path)}")
        print(f"Set CRANK_NUM_EXECUTORS={args.executors}")

    # Install systemd user service
    if remote_home:
        service_dir = f"{remote_home}/.config/systemd/user"
    else:
        service_dir = os.path.expanduser("~/.config/systemd/user")
    service_unit = (
        "[Unit]\n"
        "Description=Crank CI daemon\n"
        "After=network.target\n"
        "\n"
        "[Service]\n"
        "Type=simple\n"
        f"ExecStart={crankd_path}\n"
        f'Environment="CRANK_HOME={home}"\n'
        f"EnvironmentFile=-{conf_path}\n"
        f"WorkingDirectory={home}\n"
        "Restart=on-failure\n"
        "RestartSec=5\n"
        "\n"
        "[Install]\n"
        "WantedBy=default.target\n"
    )
    service_path = f"{service_dir}/crankd.service"
    print("Installing systemd user service...")
    _run_cmd(cfg, f"mkdir -p {_shell_quote(service_dir)}")
    _run_cmd(cfg, f"printf '%s' {_shell_quote(service_unit)} | tee {_shell_quote(service_path)} > /dev/null")
    _run_cmd(cfg, "systemctl --user daemon-reload")

    # Enable lingering so the service survives logout
    _run_cmd(cfg, "loginctl enable-linger", check=False)

    # Enable and start
    if not args.no_start:
        print("Enabling and starting crankd...")
        _run_cmd(cfg, "systemctl --user enable crankd", check=False)
        _run_cmd(cfg, "systemctl --user start crankd", check=False)

        status = _run_cmd(cfg, "systemctl --user is-active crankd", check=False)
        if status.stdout.strip() == "active":
            print("crankd is running.")
        else:
            print("Warning: crankd may not have started.", file=sys.stderr)
            _run_cmd(cfg, "systemctl --user status crankd --no-pager -l", check=False)
    else:
        print("Skipping service start (--no-start).")

    print("Init complete.")


def cmd_diff(args):
    """Compare two job scripts."""
    _validate_job_name(args.job)
    cfg = _get_config(args)

    path_a = f"{cfg['jobs_dir']}/{args.job}.run"
    result_a = _run_cmd(cfg, f"cat {_shell_quote(path_a)}", check=False)
    if result_a.returncode != 0:
        print(f"Job not found: {args.job}", file=sys.stderr)
        sys.exit(1)
    content_a = result_a.stdout

    if args.local:
        local_path = args.local
        try:
            content_b = Path(local_path).read_text()
        except FileNotFoundError:
            print(f"Local file not found: {local_path}", file=sys.stderr)
            sys.exit(1)
        except (PermissionError, IsADirectoryError, UnicodeDecodeError) as exc:
            print(f"Cannot read {local_path}: {exc}", file=sys.stderr)
            sys.exit(1)
        label_a = f"{args.job}.run (remote)" if cfg["host"] else f"{args.job}.run"
        label_b = local_path
    else:
        if not args.job2:
            print("Provide a second job name or use --local/-l with a file path.", file=sys.stderr)
            sys.exit(1)
        _validate_job_name(args.job2)
        path_b = f"{cfg['jobs_dir']}/{args.job2}.run"
        result_b = _run_cmd(cfg, f"cat {_shell_quote(path_b)}", check=False)
        if result_b.returncode != 0:
            print(f"Job not found: {args.job2}", file=sys.stderr)
            sys.exit(1)
        content_b = result_b.stdout
        label_a = f"{args.job}.run"
        label_b = f"{args.job2}.run"

    lines_a = content_a.splitlines(keepends=True)
    lines_b = content_b.splitlines(keepends=True)
    diff_output = difflib.unified_diff(lines_a, lines_b, fromfile=label_a, tofile=label_b)
    text = "".join(diff_output)
    if text:
        print(text, end="")
    else:
        print("No differences found.")


def cmd_schedule(args):
    """List all jobs with cron schedules."""
    cfg = _get_config(args)
    data = _run_python(cfg, """
import json
from pathlib import Path

jobs_dir = Path({jobs_dir})
result = []

for conf_file in sorted(jobs_dir.glob("*.conf")):
    name = conf_file.stem
    if not (jobs_dir / f"{{name}}.run").exists():
        continue

    schedule = ""
    for line in conf_file.read_text().splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        if "=" in line:
            k, v = line.split("=", 1)
            if k.strip() == "schedule":
                ci = v.find(" #")
                if ci >= 0:
                    v = v[:ci]
                schedule = v.strip()
                break

    if not schedule:
        continue

    result.append({{"name": name, "schedule": schedule}})

print(json.dumps(result))
""".format(jobs_dir=repr(cfg["jobs_dir"])))

    if not data:
        print("No scheduled jobs found.")
        return

    if getattr(args, "json", False):
        print(json.dumps(data, indent=2))
        return

    print(f"{'Job':<35} {'Schedule':<25}")
    print("-" * 60)
    for job in data:
        print(f"{job['name']:<35} {job['schedule']:<25}")


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

def main():
    parser = argparse.ArgumentParser(
        prog="crank-ctl",
        description="CLI for controlling a Crank CI server",
    )
    parser.add_argument(
        "-r", "--remote",
        help="SSH remote [user@]host (default: $CRANK_REMOTE, omit for local)",
    )
    parser.add_argument(
        "--crank-home",
        help=f"Crank home directory (default: $CRANK_HOME or {DEFAULT_CRANK_HOME})",
    )
    parser.add_argument(
        "--json", action="store_true",
        help="Output machine-readable JSON (for list, status, stats, history, logs)",
    )

    sub = parser.add_subparsers(dest="command", required=True)

    sub.add_parser("list", aliases=["ls"], help="List all jobs")
    sub.add_parser("status", aliases=["st"], help="Show running and queued jobs")

    p = sub.add_parser("trigger", aliases=["run"], help="Trigger a job")
    p.add_argument("job", help="Job name")
    p.add_argument("env", nargs="*", help="KEY=VALUE env vars")
    p.add_argument("-q", "--queue", action="store_true", help="Queue instead of blocking run")

    p = sub.add_parser("abort", help="Abort a running or queued build")
    p.add_argument("job", help="Job name")
    p.add_argument("--run", "-R", help="Specific build number to abort")

    p = sub.add_parser("retry", help="Retry the last failed or aborted build")
    p.add_argument("job", help="Job name")
    p.add_argument("--run", "-R", help="Specific build number to retry")

    p = sub.add_parser("wait", help="Wait for a build to complete")
    p.add_argument("job", help="Job name")
    p.add_argument("--run", "-R", help="Specific build number")
    p.add_argument("--timeout", "-T", type=int, default=None,
                   help="Timeout in seconds (default: no timeout)")

    p = sub.add_parser("cat", help="Print a job script")
    p.add_argument("job", help="Job name")

    p = sub.add_parser("logs", aliases=["log"], help="Show run output")
    p.add_argument("job", help="Job name")
    p.add_argument("--run", "-R", help="Specific run number")
    p.add_argument("--last", "-n", type=int, default=1, help="Number of recent runs (default: 1)")

    p = sub.add_parser("history", aliases=["hist"], help="Recent runs with pass/fail")
    p.add_argument("job", help="Job name")
    p.add_argument("--last", "-n", type=int, default=10, help="Number of runs (default: 10)")

    p = sub.add_parser("stats", help="Show aggregate statistics for a job")
    p.add_argument("job", help="Job name")
    p.add_argument("--last", "-n", type=int, default=50, help="Number of recent builds (default: 50)")

    p = sub.add_parser("tail", help="Stream live output from a running build")
    p.add_argument("job", help="Job name")
    p.add_argument("--run", "-R", help="Specific build number to tail")

    p = sub.add_parser("edit", help="Edit a job script")
    p.add_argument("job", help="Job name")

    p = sub.add_parser("create", help="Scaffold a new job script")
    p.add_argument("job", help="Job name")
    p.add_argument("-t", "--template", help="Base on an existing job")
    p.add_argument("-e", "--edit", action="store_true", help="Open in $EDITOR after creation")

    p = sub.add_parser("delete", aliases=["rm"], help="Delete a job script")
    p.add_argument("job", help="Job name")
    p.add_argument("-y", "--yes", action="store_true", help="Confirm deletion")

    p = sub.add_parser("disable", help="Disable a job")
    p.add_argument("job", help="Job name")

    p = sub.add_parser("enable", help="Enable a disabled job")
    p.add_argument("job", help="Job name")

    p = sub.add_parser("config", aliases=["cfg"], help="View/edit job config files")
    p.add_argument("job", help="Job name")
    p.add_argument("-t", "--type", default="conf", choices=["conf", "env", "before", "after"],
                   help="Config type (default: conf)")
    p.add_argument("-e", "--edit", action="store_true", help="Open in $EDITOR instead of printing")

    p = sub.add_parser("export", aliases=["backup"], help="Backup job configs as tar archive")
    p.add_argument("-o", "--output", help="Output file (default: crank-backup-<date>.tar.gz)")

    p = sub.add_parser("init", help="Initialize Crank CI on a host")
    p.add_argument("--no-start", action="store_true", help="Don't enable/start the systemd service")
    p.add_argument("--executors", "-N", type=int, help="Number of executor threads")

    sub.add_parser("schedule", aliases=["sched"], help="List jobs with cron schedules")

    p = sub.add_parser("diff", help="Compare two job scripts")
    p.add_argument("job", help="First job name")
    p.add_argument("job2", nargs="?", help="Second job name")
    p.add_argument("-l", "--local", help="Compare with a local file path")

    args = parser.parse_args()
    commands = {
        "list": cmd_list, "ls": cmd_list,
        "status": cmd_status, "st": cmd_status,
        "trigger": cmd_trigger, "run": cmd_trigger,
        "abort": cmd_abort,
        "retry": cmd_retry,
        "wait": cmd_wait,
        "cat": cmd_cat,
        "logs": cmd_logs, "log": cmd_logs,
        "history": cmd_history, "hist": cmd_history,
        "stats": cmd_stats,
        "tail": cmd_tail,
        "edit": cmd_edit,
        "create": cmd_create,
        "delete": cmd_delete, "rm": cmd_delete,
        "disable": cmd_disable,
        "enable": cmd_enable,
        "config": cmd_config, "cfg": cmd_config,
        "export": cmd_export, "backup": cmd_export,
        "init": cmd_init,
        "schedule": cmd_schedule, "sched": cmd_schedule,
        "diff": cmd_diff,
    }
    commands[args.command](args)


if __name__ == "__main__":
    main()
