#!/bin/bash
# triple-c-scheduler — CLI for managing scheduled tasks in Triple-C containers
# Tasks are stored as JSON files and crontab is rebuilt from them as the source of truth.

set -euo pipefail

SCHEDULER_DIR="${HOME}/.claude/scheduler"
TASKS_DIR="${SCHEDULER_DIR}/tasks"
LOGS_DIR="${SCHEDULER_DIR}/logs"
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
RUNNING_DIR="${SCHEDULER_DIR}/running"

# ── Helpers ──────────────────────────────────────────────────────────────────

ensure_dirs() {
    mkdir -p "$TASKS_DIR" "$LOGS_DIR" "$NOTIFICATIONS_DIR" "$RUNNING_DIR"
}

generate_id() {
    head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n'
}

# Live run state for a task: prints "pid<TAB>started_epoch<TAB>log" and returns
# 0 when the task is genuinely running, returns 1 otherwise.
#
# triple-c-task-runner writes the file and removes it from an EXIT trap, but a
# trap cannot fire for SIGKILL or a container stop mid-run. So the pid is
# checked rather than believed, and a state file whose process is gone is
# cleared here — otherwise one hard stop leaves a task reading as "running"
# forever, which is worse than no indicator at all.
run_state() {
    local id="$1"
    local state_file="${RUNNING_DIR}/${id}.json"
    [ -f "$state_file" ] || return 1

    local pid
    pid=$(jq -r '.pid // empty' "$state_file" 2>/dev/null)
    if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then
        rm -f "$state_file"
        return 1
    fi

    printf '%s\t%s\t%s\n' \
        "$pid" \
        "$(jq -r '.started_epoch // 0' "$state_file")" \
        "$(jq -r '.log // ""' "$state_file")"
}

# Compact elapsed time since an epoch, e.g. "8s", "4m12s", "1h07m".
elapsed_since() {
    local start="$1" now delta
    now=$(date +%s)
    delta=$(( now - start ))
    [ "$delta" -lt 0 ] && delta=0
    if [ "$delta" -ge 3600 ]; then
        printf '%dh%02dm' $(( delta / 3600 )) $(( (delta % 3600) / 60 ))
    elif [ "$delta" -ge 60 ]; then
        printf '%dm%02ds' $(( delta / 60 )) $(( delta % 60 ))
    else
        printf '%ds' "$delta"
    fi
}

# Reject a malformed cron expression at the point of entry.
#
# Without this an invalid schedule is written to a task file, and the next
# rebuild hands crontab a file it refuses wholesale — taking every other task
# down with it. Deliberately shape-only: five fields, each built from `*`,
# numbers, ranges, lists and steps. Names (JAN, MON) and `@daily` are not
# accepted here.
validate_cron() {
    local expr="$1"
    # Exactly five whitespace-separated fields.
    read -ra _cron_fields <<< "$expr"
    [ "${#_cron_fields[@]}" -eq 5 ] || return 1
    local field
    for field in "${_cron_fields[@]}"; do
        # A field is one or more comma-separated terms; a term is `*`, a number,
        # or a range, each optionally followed by a `/step`. Vixie only allows a
        # step after `*` or a range, which this mirrors.
        [[ "$field" =~ ^(\*(/[0-9]+)?|[0-9]+(-[0-9]+(/[0-9]+)?)?)(,(\*(/[0-9]+)?|[0-9]+(-[0-9]+(/[0-9]+)?)?))*$ ]] || return 1
    done
    return 0
}

rebuild_crontab() {
    local tmp
    tmp=$(mktemp)
    # Header
    echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$tmp"
    echo "# Do not edit manually; changes will be overwritten." >> "$tmp"
    echo "" >> "$tmp"

    for task_file in "$TASKS_DIR"/*.json; do
        [ -f "$task_file" ] || continue
        local enabled schedule id
        enabled=$(jq -r '.enabled' "$task_file")
        [ "$enabled" = "true" ] || continue
        schedule=$(jq -r '.schedule' "$task_file")
        id=$(jq -r '.id' "$task_file")
        echo "$schedule /usr/local/bin/triple-c-task-runner $id" >> "$tmp"
    done

    # `crontab` validates the WHOLE file and rejects all of it if any single
    # line is malformed. Swallowing that error silently unschedules every task
    # in the container, so report it loudly and leave the previous crontab
    # (which `crontab` keeps on rejection) in place.
    local crontab_err
    if ! crontab_err=$(crontab "$tmp" 2>&1); then
        echo "ERROR: crontab rejected the generated schedule; NO tasks are scheduled." >&2
        echo "       ${crontab_err}" >&2
        echo "       Offending file kept at ${tmp} for inspection." >&2
        echo "       Fix or remove the task with the bad schedule, then re-run any" >&2
        echo "       scheduler command to rebuild." >&2
        return 1
    fi
    rm -f "$tmp"
}

usage() {
    cat <<'EOF'
Usage: triple-c-scheduler <command> [options]

Commands:
  add          Add a new scheduled task
  remove       Remove a task
  enable       Enable a disabled task
  disable      Disable a task
  list         List all tasks
  status       Show which tasks are running right now
  logs         Show execution logs
  run          Manually trigger a task now (streams its log)
  notifications  Show or clear completion notifications

Add options:
  --name NAME          Task name (required)
  --prompt "TASK"      Task prompt for Claude (required)
  --schedule "CRON"    Cron schedule expression (for recurring tasks)
  --at "DATETIME"      Target datetime as "YYYY-MM-DD HH:MM" (for one-time tasks)
  --working-dir DIR    Working directory (default: /workspace)

Remove/Enable/Disable/Run options:
  --id ID              Task ID (required)

Status options:
  --id ID              Show one task, including its last result when idle
  --watch, -w          Refresh every 5s until the run finishes

Logs options:
  --id ID              Show logs for a specific task (optional)
  --tail N             Show last N lines (default: 50)

Notifications options:
  --clear              Clear all notifications

Examples:
  triple-c-scheduler add --name "run-tests" --schedule "*/30 * * * *" --prompt "Run the test suite and report results"
  triple-c-scheduler add --name "friday-commit" --at "2026-03-06 16:00" --prompt "Commit all changes with a descriptive message"
  triple-c-scheduler list
  triple-c-scheduler logs --id a1b2c3d4 --tail 20
  triple-c-scheduler run --id a1b2c3d4
EOF
}

# ── Commands ─────────────────────────────────────────────────────────────────

cmd_add() {
    local name="" prompt="" schedule="" at="" working_dir="/workspace"

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --name)     name="$2"; shift 2 ;;
            --prompt)   prompt="$2"; shift 2 ;;
            --schedule) schedule="$2"; shift 2 ;;
            --at)       at="$2"; shift 2 ;;
            --working-dir) working_dir="$2"; shift 2 ;;
            *) echo "Unknown option: $1" >&2; return 1 ;;
        esac
    done

    if [ -z "$name" ]; then
        echo "Error: --name is required" >&2
        return 1
    fi
    if [ -z "$prompt" ]; then
        echo "Error: --prompt is required" >&2
        return 1
    fi
    if [ -z "$schedule" ] && [ -z "$at" ]; then
        echo "Error: either --schedule or --at is required" >&2
        return 1
    fi
    if [ -n "$schedule" ] && [ -n "$at" ]; then
        echo "Error: use either --schedule or --at, not both" >&2
        return 1
    fi

    local id task_type cron_expr
    id=$(generate_id)

    if [ -n "$at" ]; then
        task_type="once"
        # Parse "YYYY-MM-DD HH:MM" into cron expression
        local year month day hour minute
        if ! [[ "$at" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})\ ([0-9]{2}):([0-9]{2})$ ]]; then
            echo "Error: --at must be in format 'YYYY-MM-DD HH:MM'" >&2
            return 1
        fi
        year="${BASH_REMATCH[1]}"
        month="${BASH_REMATCH[2]}"
        day="${BASH_REMATCH[3]}"
        hour="${BASH_REMATCH[4]}"
        minute="${BASH_REMATCH[5]}"
        # Remove leading zeros for cron
        month=$((10#$month))
        day=$((10#$day))
        hour=$((10#$hour))
        minute=$((10#$minute))
        cron_expr="$minute $hour $day $month *"
    else
        task_type="recurring"
        cron_expr="$schedule"
        if ! validate_cron "$cron_expr"; then
            echo "Error: invalid cron expression: '$cron_expr'" >&2
            echo "       Expected five fields: minute hour day-of-month month day-of-week" >&2
            echo "       e.g. '*/30 * * * *' (every 30 min), '0 9 * * 1-5' (9am weekdays)" >&2
            exit 1
        fi
    fi

    local created_at
    created_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

    local task_json
    task_json=$(jq -n \
        --arg id "$id" \
        --arg name "$name" \
        --arg prompt "$prompt" \
        --arg schedule "$cron_expr" \
        --arg type "$task_type" \
        --arg at "$at" \
        --arg created_at "$created_at" \
        --argjson enabled true \
        --arg working_dir "$working_dir" \
        '{
            id: $id,
            name: $name,
            prompt: $prompt,
            schedule: $schedule,
            type: $type,
            at: $at,
            created_at: $created_at,
            enabled: $enabled,
            working_dir: $working_dir
        }')

    echo "$task_json" > "$TASKS_DIR/${id}.json"
    rebuild_crontab

    echo "Task created:"
    echo "  ID:       $id"
    echo "  Name:     $name"
    echo "  Type:     $task_type"
    if [ "$task_type" = "once" ]; then
        echo "  At:       $at"
    fi
    echo "  Schedule: $cron_expr"
    echo "  Prompt:   $prompt"
}

cmd_remove() {
    local id=""

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --id) id="$2"; shift 2 ;;
            *) echo "Unknown option: $1" >&2; return 1 ;;
        esac
    done

    if [ -z "$id" ]; then
        echo "Error: --id is required" >&2
        return 1
    fi

    local task_file="$TASKS_DIR/${id}.json"
    if [ ! -f "$task_file" ]; then
        echo "Error: task '$id' not found" >&2
        return 1
    fi

    local name
    name=$(jq -r '.name' "$task_file")
    rm -f "$task_file"
    rebuild_crontab
    echo "Removed task '$name' ($id)"
}

cmd_enable() {
    local id=""

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --id) id="$2"; shift 2 ;;
            *) echo "Unknown option: $1" >&2; return 1 ;;
        esac
    done

    if [ -z "$id" ]; then
        echo "Error: --id is required" >&2
        return 1
    fi

    local task_file="$TASKS_DIR/${id}.json"
    if [ ! -f "$task_file" ]; then
        echo "Error: task '$id' not found" >&2
        return 1
    fi

    local tmp
    tmp=$(mktemp)
    jq '.enabled = true' "$task_file" > "$tmp" && mv "$tmp" "$task_file"
    rebuild_crontab

    local name
    name=$(jq -r '.name' "$task_file")
    echo "Enabled task '$name' ($id)"
}

cmd_disable() {
    local id=""

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --id) id="$2"; shift 2 ;;
            *) echo "Unknown option: $1" >&2; return 1 ;;
        esac
    done

    if [ -z "$id" ]; then
        echo "Error: --id is required" >&2
        return 1
    fi

    local task_file="$TASKS_DIR/${id}.json"
    if [ ! -f "$task_file" ]; then
        echo "Error: task '$id' not found" >&2
        return 1
    fi

    local tmp
    tmp=$(mktemp)
    jq '.enabled = false' "$task_file" > "$tmp" && mv "$tmp" "$task_file"
    rebuild_crontab

    local name
    name=$(jq -r '.name' "$task_file")
    echo "Disabled task '$name' ($id)"
}

cmd_list() {
    local found=false
    printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" "ID" "NAME" "TYPE" "ENABLED" "SCHEDULE" "STATUS" "PROMPT"
    printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" "──────────" "────────────────────" "──────────" "─────────" "────────────────────" "────────────" "──────────────────────────────"

    for task_file in "$TASKS_DIR"/*.json; do
        [ -f "$task_file" ] || continue
        found=true
        local id name type enabled schedule at prompt
        id=$(jq -r '.id' "$task_file")
        name=$(jq -r '.name' "$task_file")
        type=$(jq -r '.type' "$task_file")
        enabled=$(jq -r '.enabled' "$task_file")
        schedule=$(jq -r '.schedule' "$task_file")
        at=$(jq -r '.at // ""' "$task_file")
        prompt=$(jq -r '.prompt' "$task_file")

        local display_schedule="$schedule"
        if [ "$type" = "once" ] && [ -n "$at" ]; then
            display_schedule="at $at"
        fi

        local status state started
        if state=$(run_state "$id"); then
            started=$(printf '%s' "$state" | cut -f2)
            status="running $(elapsed_since "$started")"
        else
            status="idle"
        fi

        # Truncate long fields for display
        [ ${#name} -gt 20 ] && name="${name:0:17}..."
        [ ${#display_schedule} -gt 20 ] && display_schedule="${display_schedule:0:17}..."
        [ ${#prompt} -gt 30 ] && prompt="${prompt:0:27}..."

        printf "%-10s %-20s %-10s %-9s %-20s %-12s %s\n" \
            "$id" "$name" "$type" "$enabled" "$display_schedule" "$status" "$prompt"
    done

    if [ "$found" = "false" ]; then
        echo "No scheduled tasks."
    fi
}

# Is anything running, and how far along is it?
#
# This is the command for the question "did my `run` do anything, or has it
# stalled?" — `logs` alone cannot answer it, because a log that stops growing
# looks identical whether Claude is thinking or the run is dead.
cmd_status() {
    local id="" watch=false

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --id)    id="$2"; shift 2 ;;
            --watch|-w) watch=true; shift ;;
            *) echo "Unknown option: $1" >&2; return 1 ;;
        esac
    done

    while true; do
        local any=false
        for task_file in "$TASKS_DIR"/*.json; do
            [ -f "$task_file" ] || continue
            local tid
            tid=$(jq -r '.id' "$task_file")
            [ -z "$id" ] || [ "$tid" = "$id" ] || continue

            local name state
            name=$(jq -r '.name' "$task_file")
            if state=$(run_state "$tid"); then
                any=true
                local pid started log
                pid=$(printf '%s' "$state" | cut -f1)
                started=$(printf '%s' "$state" | cut -f2)
                log=$(printf '%s' "$state" | cut -f3)
                echo "● RUNNING  $name ($tid)"
                echo "    elapsed: $(elapsed_since "$started")   pid: $pid"
                echo "    log:     $log"
                # `claude -p` writes its answer in one go at the end, so a log
                # with only its header is the normal state of a healthy run —
                # print the tail only when there is something to show, rather
                # than an empty "last output:" that reads like a stall.
                # `|| true` throughout: under `set -e` a grep matching nothing
                # would otherwise abort the whole command.
                local tail_out=""
                if [ -f "$log" ]; then
                    tail_out=$({ grep -v '^===' "$log" || true; } \
                        | { grep -v '^$' || true; } | tail -n 3)
                fi
                if [ -n "$tail_out" ]; then
                    echo "    last output:"
                    printf '%s\n' "$tail_out" | sed 's/^/      /'
                fi
            elif [ -n "$id" ]; then
                echo "○ idle     $name ($tid)"
                local latest
                latest=$(ls -t "$LOGS_DIR/$tid"/*.log 2>/dev/null | head -1) || true
                if [ -n "$latest" ]; then
                    echo "    last run: $(basename "$latest" .log)  $(grep -o 'Exit code: [0-9]*' "$latest" | tail -1)"
                fi
            fi
        done

        if [ "$any" = "false" ] && [ -z "$id" ]; then
            echo "Nothing running."
        fi

        [ "$watch" = "true" ] || break
        # Stop watching once the thing being watched has finished.
        [ "$any" = "true" ] || break
        sleep 5
        echo ""
    done
}

cmd_logs() {
    local id="" tail_n=50

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --id)   id="$2"; shift 2 ;;
            --tail) tail_n="$2"; shift 2 ;;
            *) echo "Unknown option: $1" >&2; return 1 ;;
        esac
    done

    if [ -n "$id" ]; then
        local log_dir="$LOGS_DIR/$id"
        if [ ! -d "$log_dir" ]; then
            echo "No logs found for task '$id'"
            return 0
        fi
        # Show the most recent log file
        local latest
        latest=$(ls -t "$log_dir"/*.log 2>/dev/null | head -1)
        if [ -z "$latest" ]; then
            echo "No logs found for task '$id'"
            return 0
        fi
        echo "=== Latest log for task $id: $(basename "$latest") ==="
        tail -n "$tail_n" "$latest"
    else
        # Show recent logs across all tasks
        local all_logs
        all_logs=$(find "$LOGS_DIR" -name "*.log" -type f 2>/dev/null | sort -r | head -n 10)
        if [ -z "$all_logs" ]; then
            echo "No logs found."
            return 0
        fi
        for log_file in $all_logs; do
            local task_id
            task_id=$(basename "$(dirname "$log_file")")
            echo "=== Task $task_id: $(basename "$log_file") ==="
            tail -n 5 "$log_file"
            echo ""
        done
    fi
}

cmd_run() {
    local id=""

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --id) id="$2"; shift 2 ;;
            *) echo "Unknown option: $1" >&2; return 1 ;;
        esac
    done

    if [ -z "$id" ]; then
        echo "Error: --id is required" >&2
        return 1
    fi

    local task_file="$TASKS_DIR/${id}.json"
    if [ ! -f "$task_file" ]; then
        echo "Error: task '$id' not found" >&2
        return 1
    fi

    local name
    name=$(jq -r '.name' "$task_file")

    if run_state "$id" >/dev/null; then
        echo "Task '$name' ($id) is already running — see: triple-c-scheduler status --id $id"
        return 0
    fi

    echo "Manually triggering task '$name' ($id)..."

    # Run in the background and stream its log. A task can easily think for
    # minutes, and the previous behaviour — block with no output until it is
    # over — is indistinguishable from a hang.
    /usr/local/bin/triple-c-task-runner "$id" &
    local runner_pid=$!

    local state="" waited=0
    while [ "$waited" -lt 20 ]; do
        if state=$(run_state "$id"); then
            break
        fi
        kill -0 "$runner_pid" 2>/dev/null || break
        sleep 0.5
        waited=$(( waited + 1 ))
    done

    local log=""
    [ -n "$state" ] && log=$(printf '%s' "$state" | cut -f3)

    if [ -n "$log" ]; then
        echo "  log:     $log"
        echo "  elsewhere: triple-c-scheduler status --id $id --watch"
        echo ""
        # --pid stops the follow when the runner exits, so this returns on its own.
        tail -n +1 -f --pid="$runner_pid" "$log" 2>/dev/null
    fi

    local rc=0
    wait "$runner_pid" || rc=$?

    # A run short enough that its state file was never observed still deserves
    # its output shown rather than swallowed.
    if [ -z "$log" ]; then
        local latest
        latest=$(ls -t "$LOGS_DIR/$id"/*.log 2>/dev/null | head -1) || true
        [ -n "$latest" ] && tail -n 20 "$latest"
    fi

    return $rc
}

cmd_notifications() {
    local clear=false

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --clear) clear=true; shift ;;
            *) echo "Unknown option: $1" >&2; return 1 ;;
        esac
    done

    if [ "$clear" = "true" ]; then
        rm -f "$NOTIFICATIONS_DIR"/*.notify
        echo "Notifications cleared."
        return 0
    fi

    local found=false
    for notify_file in $(ls -t "$NOTIFICATIONS_DIR"/*.notify 2>/dev/null); do
        [ -f "$notify_file" ] || continue
        found=true
        cat "$notify_file"
        echo "---"
    done

    if [ "$found" = "false" ]; then
        echo "No notifications."
    fi
}

# ── Main ─────────────────────────────────────────────────────────────────────

ensure_dirs

if [ $# -eq 0 ]; then
    usage
    exit 1
fi

command="$1"
shift

case "$command" in
    add)           cmd_add "$@" ;;
    remove)        cmd_remove "$@" ;;
    enable)        cmd_enable "$@" ;;
    disable)       cmd_disable "$@" ;;
    list)          cmd_list ;;
    status)        cmd_status "$@" ;;
    logs)          cmd_logs "$@" ;;
    run)           cmd_run "$@" ;;
    notifications) cmd_notifications "$@" ;;
    help|--help|-h) usage ;;
    *)
        echo "Unknown command: $command" >&2
        usage
        exit 1
        ;;
esac
