#!/usr/bin/env bash
# Loki Mode Event Emitter - Bash helper for emitting events
#
# Usage:
#   ./emit.sh <type> <source> <action> [key=value ...]
#
# Examples:
#   ./emit.sh session cli start provider=claude
#   ./emit.sh task runner complete task_id=task-001
#   ./emit.sh error hook failed error="Command blocked"
#
# Environment:
#   LOKI_DIR - Path to .loki directory (default: .loki)
#
# Sourcing:
#   This script can also be sourced (LOKI_EMIT_LIB_ONLY=1) to expose the
#   safe_append_event_jsonl() helper without performing an emit.

# safe_append_event_jsonl <events_jsonl_path> <line>
#
# Cross-process serialized append to .loki/events.jsonl. POSIX append is
# atomic only for writes <PIPE_BUF (typically 4KB) and not all filesystems
# honor it; under parallel-worktree contention bare `>>` can interleave
# partial JSONL lines. This helper serializes appends across processes.
#
# Strategy (v7.5.10):
#   - Prefer flock(1) when available (Linux, util-linux). Uses an
#     exclusive lock on a sentinel FD bound to <events>.lock so the lock
#     is released automatically when the subshell exits.
#   - Fall back to a mkdir() mutex on macOS / BSDs where flock is not
#     installed by default. mkdir is atomic on POSIX -- exactly one
#     concurrent caller wins the create. We retry with backoff up to
#     ~5s, and treat a stale lockdir (>30s old) as abandonable.
#   - The newline is appended by the helper -- callers pass the JSON
#     payload only.
safe_append_event_jsonl() {
    local events_path="$1"
    local line="$2"
    local lock_target="${events_path}.lock"
    local events_dir
    events_dir="$(dirname "$events_path")"
    mkdir -p "$events_dir" 2>/dev/null || true

    if command -v flock >/dev/null 2>&1; then
        # flock path: bind FD 9 to the sentinel file (created if absent),
        # take an exclusive lock, append, release on subshell exit.
        (
            flock -x 9
            printf '%s\n' "$line" >> "$events_path"
        ) 9>"$lock_target"
        return $?
    fi

    # Fallback: mkdir-based mutex. mkdir is atomic on POSIX.
    local lock_dir="${events_path}.lockdir"
    local attempts=0
    local max_attempts=500   # ~5s at 10ms sleep
    while ! mkdir "$lock_dir" 2>/dev/null; do
        attempts=$((attempts + 1))
        if [ "$attempts" -ge "$max_attempts" ]; then
            # Stale lock: if the dir is older than 30s, force-remove it.
            local age
            age=$(( $(date +%s) - $(stat -f%m "$lock_dir" 2>/dev/null \
                                    || stat -c%Y "$lock_dir" 2>/dev/null \
                                    || echo 0) ))
            if [ "$age" -gt 30 ]; then
                rmdir "$lock_dir" 2>/dev/null || rm -rf "$lock_dir" 2>/dev/null || true
                attempts=0
                continue
            fi
            # Give up -- best-effort write so observability never blocks.
            printf '%s\n' "$line" >> "$events_path" 2>/dev/null || true
            return 1
        fi
        # Sleep ~10ms (perl avoids `sleep 0.01` portability issues).
        perl -e 'select(undef,undef,undef,0.01)' 2>/dev/null || sleep 1
    done
    # Critical section.
    printf '%s\n' "$line" >> "$events_path"
    local rc=$?
    rmdir "$lock_dir" 2>/dev/null || true
    return $rc
}

# Library-only mode: source this file to get safe_append_event_jsonl
# without executing the emit logic below.
if [ "${LOKI_EMIT_LIB_ONLY:-0}" = "1" ]; then
    return 0 2>/dev/null || exit 0
fi

set -euo pipefail

# Configuration
LOKI_DIR="${LOKI_DIR:-.loki}"
EVENTS_DIR="$LOKI_DIR/events/pending"

# Ensure directory exists
mkdir -p "$EVENTS_DIR"

# Arguments
TYPE="${1:-state}"
SOURCE="${2:-cli}"
ACTION="${3:-unknown}"
if [ "$#" -ge 3 ]; then shift 3; else shift "$#"; fi

# Generate event ID and timestamp
EVENT_ID=$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n')
# Try GNU date %N (nanoseconds) first, fall back to python3, then .000Z
if date --version >/dev/null 2>&1; then
    # GNU date (Linux)
    TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ")
elif command -v python3 >/dev/null 2>&1; then
    # macOS fallback: use python3 for milliseconds
    TIMESTAMP=$(python3 -c "from datetime import datetime, timezone; print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z')")
else
    # Final fallback: .000Z
    TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
fi

# JSON escape helper: handles \, ", and control characters including newlines
#
# The sed pass escapes backslash, double-quote, tab and carriage-return to
# their named short forms (\\ \" \t \r); the first awk pass collapses embedded
# newlines to \n. Every REMAINING C0 control byte (0x01-0x08, 0x0B-0x0C,
# 0x0E-0x1F -- which includes backspace 0x08 and form-feed 0x0C) is invalid raw
# inside a JSON string and is escaped as \uXXXX by the final awk pass (its map
# covers all of i=1..31). Without that escaping json.loads / JSON.parse reject
# the line and consumers (dashboard _read_events, learning aggregator) silently
# drop it. The named short forms emitted by sed are already two-char ASCII by
# the time the final pass runs, so they are never re-escaped.
#
# NOTE: backspace/form-feed are deliberately NOT escaped in the sed pass.
# Doing so requires embedding the raw control bytes in the sed pattern, which
# editors silently strip -- that left the two empty-pattern sed no-ops this
# helper previously carried. An empty sed regex reuses the LAST applied regex,
# so those entries were dead AND a latent corruption hazard. The final awk
# pass already escapes every byte in i=1..31 (backspace and form-feed included)
# to the six-char uXXXX unicode escape, producing valid JSON, so the
# named-short-form variants are unnecessary.
json_escape() {
    printf '%s' "$1" \
        | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g' \
        | awk '{if(NR>1) printf "\\n"; printf "%s", $0}' \
        | awk 'BEGIN{for(i=1;i<=31;i++)m[sprintf("%c",i)]=sprintf("\\u%04x",i)}
               {s=""; n=length($0);
                for(i=1;i<=n;i++){c=substr($0,i,1); s=s (c in m ? m[c] : c)}
                printf "%s", s}'
}

# Build payload JSON
ACTION_ESC=$(json_escape "$ACTION")
PAYLOAD="{\"action\":\"$ACTION_ESC\""
for arg in "$@"; do
    key="${arg%%=*}"
    value="${arg#*=}"
    key_escaped=$(json_escape "$key")
    value_escaped=$(json_escape "$value")
    PAYLOAD="$PAYLOAD,\"$key_escaped\":\"$value_escaped\""
done
PAYLOAD="$PAYLOAD}"

# Build full event JSON (escape type/source for safe embedding)
TYPE_ESC=$(json_escape "$TYPE")
SOURCE_ESC=$(json_escape "$SOURCE")
EVENT=$(cat <<EOF
{
  "id": "$EVENT_ID",
  "type": "$TYPE_ESC",
  "source": "$SOURCE_ESC",
  "timestamp": "$TIMESTAMP",
  "payload": $PAYLOAD,
  "version": "1.0"
}
EOF
)

# Write event file atomically.
#
# A bare `echo "$EVENT" > "$EVENT_FILE"` is NON-atomic: a reader (bus.py
# get_pending_events / from_dict, dashboard) that globs `*.json` mid-write sees
# a truncated/partial file and either fails json.loads (event silently dropped)
# or, worse, reads a half-written record. Write to a sibling temp file in the
# SAME directory (so rename(2) is an atomic same-filesystem operation) and then
# rename into place; a `.tmp` suffix keeps the in-progress file out of the
# `*.json` glob readers use. Clean up the temp on any failure so partials never
# linger.
EVENT_FILE="$EVENTS_DIR/${TIMESTAMP//:/-}_$EVENT_ID.json"
EVENT_TMP="$EVENT_FILE.tmp"
if printf '%s\n' "$EVENT" > "$EVENT_TMP" && mv -f "$EVENT_TMP" "$EVENT_FILE"; then
    :
else
    rm -f "$EVENT_TMP" 2>/dev/null || true
fi

# Rotate events.jsonl if it exceeds 50MB (keep 1 backup)
EVENTS_LOG="$LOKI_DIR/events.jsonl"
if [ -f "$EVENTS_LOG" ]; then
    # Check file size (in bytes)
    FILE_SIZE=$(stat -f%z "$EVENTS_LOG" 2>/dev/null || stat -c%s "$EVENTS_LOG" 2>/dev/null || echo 0)
    MAX_SIZE=$((50 * 1024 * 1024))  # 50MB
    if [ "$FILE_SIZE" -gt "$MAX_SIZE" ]; then
        mv "$EVENTS_LOG" "$EVENTS_LOG.1" 2>/dev/null || true
    fi
fi

# Append a flat-schema record to .loki/events.jsonl for dashboard consumption.
#
# The dashboard reads .loki/events.jsonl directly (dashboard/server.py
# _read_events) and run.sh's emit_event/emit_event_json write the FLAT schema
# {"timestamp","type","data"} -- NOT the nested pending schema written to the
# per-event file above. Without this append, events emitted via emit.sh land
# only in the pending dir and stay INVISIBLE to the dashboard.
#
# Mapping: data = the existing PAYLOAD object (mirrors emit_event_json, where
# `data` is a JSON object). `source` is intentionally dropped from the flat
# record (not part of the dashboard schema); the pending file above preserves
# it for other consumers. PAYLOAD is already newline-free (built on lines
# 127-135), so the record is a single compact line. The helper appends its own
# trailing newline. `|| true` keeps observability from ever aborting the emit
# under `set -e` (matches autonomy/run.sh:9896).
FLAT_EVENT="{\"timestamp\":\"$TIMESTAMP\",\"type\":\"$TYPE_ESC\",\"data\":$PAYLOAD}"
safe_append_event_jsonl "$EVENTS_LOG" "$FLAT_EVENT" 2>/dev/null || true

# Output event ID
echo "$EVENT_ID"
