#!/usr/bin/env bash
set -euo pipefail

# OpusClip CLI — wrapper for the OpusClip API
# https://help.opus.pro/api-reference/overview
#
# Requires: curl, jq
# Auth: Set OPUSCLIP_API_KEY environment variable

VERSION="2.2.3"
API_BASE="${OPUSCLIP_API_URL:-https://api.opus.pro/api}"

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

die()  { echo "error: $*" >&2; exit 1; }
need() {
  local bin="$1" hint="${2:-}"
  if ! command -v "$bin" >/dev/null 2>&1; then
    if [[ -n "$hint" ]]; then
      die "$bin is required for this command but not installed. $hint"
    else
      die "$bin is required but not installed"
    fi
  fi
}

# Whether ffmpeg has a given filter (e.g. drawtext, requires libfreetype).
ffmpeg_has_filter() {
  ffmpeg -hide_banner -filters 2>/dev/null | awk '{print $2}' | grep -qx "$1"
}

# Strip project prefix from composite clip IDs (e.g. "P123.ClipABC" → "ClipABC")
clip_suffix() {
  local id="$1" pid="${2:-}"
  if [[ -n "$pid" && "$id" == "$pid."* ]]; then
    echo "${id#"$pid".}"
  elif [[ "$id" == *.* ]]; then
    echo "${id#*.}"
  else
    echo "$id"
  fi
}

need curl
need jq

api_key() {
  [[ -n "${OPUSCLIP_API_KEY:-}" ]] || die "OPUSCLIP_API_KEY is not set. API access requires an Enterprise or Pro plan: https://www.opus.pro/pricing?utm_source=cli&utm_medium=opus"
  echo "$OPUSCLIP_API_KEY"
}

# Standard headers for authenticated requests
auth_headers() {
  echo -H "Authorization: Bearer $(api_key)" -H "Content-Type: application/json" -H "Accept: application/json"
}

# Pretty-print JSON
output() { jq .; }

EXTRA_HEADER_FLAGS=()
if [[ -n "${OPUSCLIP_EXTRA_HEADERS:-}" ]]; then
  IFS=';' read -ra _hdr_parts <<< "$OPUSCLIP_EXTRA_HEADERS"
  for _h in "${_hdr_parts[@]}"; do
    _h="${_h#"${_h%%[![:space:]]*}"}"
    _h="${_h%"${_h##*[![:space:]]}"}"
    [[ -n "$_h" ]] && EXTRA_HEADER_FLAGS+=(-H "$_h")
  done
  unset _hdr_parts _h
fi

# ── API calls ────────────────────────────────────────────────────────────────

# POST with JSON body
api_post() {
  local url="$1"; shift
  local body="$1"; shift
  curl -sS -X POST "$url" \
    -H "Authorization: Bearer $(api_key)" \
    -H "Content-Type: application/json" \
    -H "Accept: application/json" \
    ${EXTRA_HEADER_FLAGS[@]+"${EXTRA_HEADER_FLAGS[@]}"} \
    -d "$body" "$@"
}

# GET with query string
api_get() {
  local url="$1"; shift
  curl -sS -X GET "$url" \
    -H "Authorization: Bearer $(api_key)" \
    -H "Accept: application/json" \
    ${EXTRA_HEADER_FLAGS[@]+"${EXTRA_HEADER_FLAGS[@]}"} \
    "$@"
}

# DELETE
api_delete() {
  local url="$1"; shift
  curl -sS -X DELETE "$url" \
    -H "Authorization: Bearer $(api_key)" \
    -H "Accept: application/json" \
    ${EXTRA_HEADER_FLAGS[@]+"${EXTRA_HEADER_FLAGS[@]}"} \
    "$@"
}

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

cmd_create_project() {
  local video_url="" template_id="" model="" genre="" keywords="" custom_prompt=""
  local aspect="portrait" skip_curate="false" source_lang="" webhook_url=""
  local clip_durations="" range_start="" range_end="" title=""
  local remove_filler="false"

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --url)             video_url="$2"; shift 2 ;;
      --template)        template_id="$2"; shift 2 ;;
      --model)           model="$2"; shift 2 ;;
      --genre)           genre="$2"; shift 2 ;;
      --keywords)        keywords="$2"; shift 2 ;;
      --prompt)          custom_prompt="$2"; shift 2 ;;
      --aspect)          aspect="$2"; shift 2 ;;
      --skip-curate)     skip_curate="true"; shift ;;
      --lang)            source_lang="$2"; shift 2 ;;
      --webhook)         webhook_url="$2"; shift 2 ;;
      --durations)       clip_durations="$2"; shift 2 ;;
      --range-start)     range_start="$2"; shift 2 ;;
      --range-end)       range_end="$2"; shift 2 ;;
      --title)           title="$2"; shift 2 ;;
      --remove-filler)   remove_filler="true"; shift ;;
      *) die "create-project: unknown flag '$1'" ;;
    esac
  done

  [[ -n "$video_url" ]] || die "create-project: --url is required"

  # Build JSON payload with jq
  local payload
  payload=$(jq -n \
    --arg videoUrl "$video_url" \
    '{videoUrl: $videoUrl}')

  if [[ -n "$template_id" ]]; then
    payload=$(echo "$payload" | jq --arg v "$template_id" '. + {brandTemplateId: $v}')
  fi

  if [[ -n "$title" ]]; then
    payload=$(echo "$payload" | jq --arg v "$title" '. + {uploadedVideoAttr: {title: $v}}')
  fi

  # curationPref
  local curation="{}"
  if [[ -n "$model" ]]; then
    curation=$(echo "$curation" | jq --arg v "$model" '. + {model: $v}')
  fi
  if [[ -n "$genre" ]]; then
    curation=$(echo "$curation" | jq --arg v "$genre" '. + {genre: $v}')
  fi
  if [[ -n "$keywords" ]]; then
    curation=$(echo "$curation" | jq --arg v "$keywords" '. + {topicKeywords: ($v | split(","))}')
  fi
  if [[ -n "$custom_prompt" ]]; then
    curation=$(echo "$curation" | jq --arg v "$custom_prompt" '. + {customPrompt: $v}')
  fi
  if [[ -n "$clip_durations" ]]; then
    curation=$(echo "$curation" | jq --arg v "$clip_durations" '. + {clipDurations: ($v | split(",") | map(tonumber) | map([0, .]))}')
  fi
  if [[ "$skip_curate" == "true" ]]; then
    curation=$(echo "$curation" | jq '. + {skipCurate: true}')
  fi
  if [[ -n "$range_start" || -n "$range_end" ]]; then
    local range="{}"
    [[ -n "$range_start" ]] && range=$(echo "$range" | jq --arg v "$range_start" '. + {startSec: ($v | tonumber)}')
    [[ -n "$range_end" ]]   && range=$(echo "$range" | jq --arg v "$range_end" '. + {endSec: ($v | tonumber)}')
    curation=$(echo "$curation" | jq --argjson r "$range" '. + {range: $r}')
  fi
  if [[ "$curation" != "{}" ]]; then
    payload=$(echo "$payload" | jq --argjson c "$curation" '. + {curationPref: $c}')
  fi

  # renderPref
  local render="{}"
  render=$(echo "$render" | jq --arg v "$aspect" '. + {layoutAspectRatio: $v}')
  if [[ "$remove_filler" == "true" ]]; then
    render=$(echo "$render" | jq '. + {quickstartConfig: {enableRemoveFillerWords: true}}')
  fi
  payload=$(echo "$payload" | jq --argjson r "$render" '. + {renderPref: $r}')

  # importPreference
  if [[ -n "$source_lang" ]]; then
    payload=$(echo "$payload" | jq --arg v "$source_lang" '. + {importPreference: {sourceLang: $v}}')
  fi

  # conclusionActions
  if [[ -n "$webhook_url" ]]; then
    payload=$(echo "$payload" | jq --arg v "$webhook_url" '. + {conclusionActions: [{type: "WEBHOOK", url: $v}]}')
  fi

  api_post "$API_BASE/clip-projects" "$payload" | output
}

cmd_get_clips() {
  local project_id="" collection_id="" summary="false"

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --project)    project_id="$2"; shift 2 ;;
      --collection) collection_id="$2"; shift 2 ;;
      --summary)    summary="true"; shift ;;
      *) die "get-clips: unknown flag '$1'" ;;
    esac
  done

  local url
  if [[ -n "$project_id" ]]; then
    url="$API_BASE/exportable-clips?q=findByProjectId&projectId=$project_id"
  elif [[ -n "$collection_id" ]]; then
    url="$API_BASE/exportable-clips?q=findByCollectionId&collectionId=$collection_id"
  else
    die "get-clips: --project or --collection is required"
  fi

  local filter='[.data[] | {
    project_id: (.id | split(".") | .[0]),
    clip_id: (.id | split(".") | .[1:] | join(".")),
    rank: .rank,
    score: .score,
    title: .title,
    description: .description,
    hashtags: .hashtags,
    duration_sec: ((.durationMs // 0) / 1000 | round),
    is_bonus: .isBonusClip,
    preview_url: .uriForPreview,
    export_url: .uriForExport,
    thumbnail_url: .uriForThumbnail
  }]'

  if [[ "$summary" == "true" ]]; then
    filter='[.data[] | {
      project_id: (.id | split(".") | .[0]),
      clip_id: (.id | split(".") | .[1:] | join(".")),
      rank: .rank,
      score: .score,
      title: .title,
      description: .description,
      hashtags: .hashtags,
      duration_sec: ((.durationMs // 0) / 1000 | round),
      hook_score: .judgeResult.hookScore,
      coherence_score: .judgeResult.coherenceScore,
      connection_score: .judgeResult.connectionScore,
      trend_score: .judgeResult.trendScore
    }]'
  fi

  api_get "$url" | jq "$filter"
}

cmd_share_project() {
  local project_id="" visibility="PUBLIC"

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --project)    project_id="$2"; shift 2 ;;
      --visibility) visibility="$2"; shift 2 ;;
      *) die "share-project: unknown flag '$1'" ;;
    esac
  done

  [[ -n "$project_id" ]] || die "share-project: --project is required"

  local payload
  payload=$(jq -n --arg v "$visibility" '{visibility: $v}')

  api_post "$API_BASE/clip-projects/$project_id/update-visibility" "$payload" | output
}

cmd_templates() {
  api_get "$API_BASE/brand-templates?q=mine" | output
}

cmd_upload() {
  local file="" title="" template_id="" model="" genre="" keywords="" custom_prompt=""
  local aspect="portrait" skip_curate="false" source_lang="" webhook_url=""
  local clip_durations="" range_start="" range_end="" remove_filler="false"

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --file)            file="$2"; shift 2 ;;
      --title)           title="$2"; shift 2 ;;
      --template)        template_id="$2"; shift 2 ;;
      --model)           model="$2"; shift 2 ;;
      --genre)           genre="$2"; shift 2 ;;
      --keywords)        keywords="$2"; shift 2 ;;
      --prompt)          custom_prompt="$2"; shift 2 ;;
      --aspect)          aspect="$2"; shift 2 ;;
      --skip-curate)     skip_curate="true"; shift ;;
      --lang)            source_lang="$2"; shift 2 ;;
      --webhook)         webhook_url="$2"; shift 2 ;;
      --durations)       clip_durations="$2"; shift 2 ;;
      --range-start)     range_start="$2"; shift 2 ;;
      --range-end)       range_end="$2"; shift 2 ;;
      --remove-filler)   remove_filler="true"; shift ;;
      *) die "upload: unknown flag '$1'" ;;
    esac
  done

  [[ -n "$file" ]] || die "upload: --file is required"
  [[ -f "$file" ]] || die "upload: file '$file' not found"

  echo "Step 1/4: Requesting upload link..." >&2
  local upload_resp
  upload_resp=$(api_post "$API_BASE/upload-links" '{"video":{"usecase":"LocalUpload"}}')

  local gcs_url upload_id
  gcs_url=$(echo "$upload_resp" | jq -r '.url')
  upload_id=$(echo "$upload_resp" | jq -r '.uploadId')

  [[ "$gcs_url" != "null" ]] || die "upload: failed to get upload URL. Response: $upload_resp"

  echo "Step 2/4: Initiating resumable upload session..." >&2
  local session_url
  session_url=$(curl -sS -D - -o /dev/null -X POST "$gcs_url" \
    -H "x-goog-resumable: start" \
    -H "Content-Length: 0" | grep -i "^location:" | tr -d '\r' | sed 's/^[Ll]ocation: //')

  [[ -n "$session_url" ]] || die "upload: failed to get resumable session URL"

  echo "Step 3/4: Uploading video file..." >&2
  curl -sS -X PUT "$session_url" \
    -H "Content-Type: application/octet-stream" \
    --data-binary "@$file" > /dev/null

  echo "Step 4/4: Creating clip project..." >&2
  # Build args for create-project, reusing the upload_id as the video URL
  local args=(--url "$upload_id")
  [[ -n "$title" ]]          && args+=(--title "$title")
  [[ -n "$template_id" ]]    && args+=(--template "$template_id")
  [[ -n "$model" ]]          && args+=(--model "$model")
  [[ -n "$genre" ]]          && args+=(--genre "$genre")
  [[ -n "$keywords" ]]       && args+=(--keywords "$keywords")
  [[ -n "$custom_prompt" ]]  && args+=(--prompt "$custom_prompt")
  [[ "$aspect" != "portrait" ]] && args+=(--aspect "$aspect")
  [[ "$skip_curate" == "true" ]] && args+=(--skip-curate)
  [[ -n "$source_lang" ]]    && args+=(--lang "$source_lang")
  [[ -n "$webhook_url" ]]    && args+=(--webhook "$webhook_url")
  [[ -n "$clip_durations" ]] && args+=(--durations "$clip_durations")
  [[ -n "$range_start" ]]    && args+=(--range-start "$range_start")
  [[ -n "$range_end" ]]      && args+=(--range-end "$range_end")
  [[ "$remove_filler" == "true" ]] && args+=(--remove-filler)

  cmd_create_project "${args[@]}"
}

# ── Collections ──────────────────────────────────────────────────────────────

cmd_collections() {
  local subcmd="${1:-list}"; shift 2>/dev/null || true

  case "$subcmd" in
    list)
      local query="mine" content_id=""
      while [[ $# -gt 0 ]]; do
        case "$1" in
          --content-id) query="findByContentId"; content_id="$2"; shift 2 ;;
          *) die "collections list: unknown flag '$1'" ;;
        esac
      done
      local url="$API_BASE/collections?q=$query"
      [[ -n "$content_id" ]] && url="$url&contentId=$content_id"
      api_get "$url" | output
      ;;

    create)
      local name=""
      while [[ $# -gt 0 ]]; do
        case "$1" in
          --name) name="$2"; shift 2 ;;
          *) die "collections create: unknown flag '$1'" ;;
        esac
      done
      [[ -n "$name" ]] || die "collections create: --name is required"
      local payload
      payload=$(jq -n --arg v "$name" '{collectionName: $v}')
      api_post "$API_BASE/collections" "$payload" | output
      ;;

    delete)
      local collection_id=""
      while [[ $# -gt 0 ]]; do
        case "$1" in
          --id) collection_id="$2"; shift 2 ;;
          *) die "collections delete: unknown flag '$1'" ;;
        esac
      done
      [[ -n "$collection_id" ]] || die "collections delete: --id is required"
      api_delete "$API_BASE/collections/$collection_id" | output
      ;;

    export)
      local collection_id=""
      while [[ $# -gt 0 ]]; do
        case "$1" in
          --id) collection_id="$2"; shift 2 ;;
          *) die "collections export: unknown flag '$1'" ;;
        esac
      done
      [[ -n "$collection_id" ]] || die "collections export: --id is required"
      api_post "$API_BASE/collections/$collection_id/export" "{}" | output
      ;;

    add-clip)
      local collection_id="" content_id=""
      while [[ $# -gt 0 ]]; do
        case "$1" in
          --id)         collection_id="$2"; shift 2 ;;
          --content-id) content_id="$2"; shift 2 ;;
          *) die "collections add-clip: unknown flag '$1'" ;;
        esac
      done
      [[ -n "$collection_id" ]] || die "collections add-clip: --id is required"
      [[ -n "$content_id" ]]    || die "collections add-clip: --content-id is required"
      local payload
      payload=$(jq -n --arg cid "$collection_id" --arg xid "$content_id" \
        '{collectionId: $cid, contentId: $xid}')
      api_post "$API_BASE/collection-contents" "$payload" | output
      ;;

    remove-clip)
      local collection_id="" content_id=""
      while [[ $# -gt 0 ]]; do
        case "$1" in
          --id)         collection_id="$2"; shift 2 ;;
          --content-id) content_id="$2"; shift 2 ;;
          *) die "collections remove-clip: unknown flag '$1'" ;;
        esac
      done
      [[ -n "$collection_id" ]] || die "collections remove-clip: --id is required"
      [[ -n "$content_id" ]]    || die "collections remove-clip: --content-id is required"
      local payload
      payload=$(jq -n --arg cid "$collection_id" --arg xid "$content_id" \
        '{q: "findByCollectionIdAndContentId", collectionId: $cid, contentId: $xid}')
      api_post "$API_BASE/collection-contents/delete-collection-contents" "$payload" | output
      ;;

    *)
      die "collections: unknown subcommand '$subcmd' (use list|create|delete|export|add-clip|remove-clip)"
      ;;
  esac
}

# ── Censor ───────────────────────────────────────────────────────────────────

# ── Post (Social Posting) ─────────────────────────────────────────────────

cmd_post() {
  local subcmd="${1:-accounts}"; shift 2>/dev/null || true

  case "$subcmd" in
    accounts)
      local filter='[.data[] | {
        postAccountId: .postAccountId,
        subAccountId: .subAccountId,
        platform: .platform,
        name: .extUserName,
        profile_url: .extUserProfileLink
      }]'
      api_get "$API_BASE/social-accounts?q=mine" | jq "$filter"
      ;;

    generate-copy)
      local project_id="" clip_id="" account_id="" sub_account="" prompt="" force="false"
      while [[ $# -gt 0 ]]; do
        case "$1" in
          --project)     project_id="$2"; shift 2 ;;
          --clip)        clip_id="$2"; shift 2 ;;
          --account)     account_id="$2"; shift 2 ;;
          --sub-account) sub_account="$2"; shift 2 ;;
          --prompt)      prompt="$2"; shift 2 ;;
          --force)       force="true"; shift ;;
          *) die "post generate-copy: unknown flag '$1'" ;;
        esac
      done
      [[ -n "$project_id" ]] || die "post generate-copy: --project is required"
      [[ -n "$clip_id" ]]    || die "post generate-copy: --clip is required"
      [[ -n "$account_id" ]] || die "post generate-copy: --account is required"
      clip_id=$(clip_suffix "$clip_id" "$project_id")

      local payload
      payload=$(jq -n \
        --arg pid "$project_id" --arg cid "$clip_id" --arg aid "$account_id" \
        '{projectId: $pid, clipId: $cid, postAccountId: $aid}')
      [[ -n "$sub_account" ]] && payload=$(echo "$payload" | jq --arg v "$sub_account" '. + {subAccountId: $v}')
      [[ -n "$prompt" ]]      && payload=$(echo "$payload" | jq --arg v "$prompt" '. + {prompt: $v}')
      [[ "$force" == "true" ]] && payload=$(echo "$payload" | jq '. + {forceRegenerate: true}')

      api_post "$API_BASE/social-copy-jobs" "$payload" | output
      ;;

    copy-status)
      local job_id=""
      while [[ $# -gt 0 ]]; do
        case "$1" in
          --job) job_id="$2"; shift 2 ;;
          *) die "post copy-status: unknown flag '$1'" ;;
        esac
      done
      [[ -n "$job_id" ]] || die "post copy-status: --job is required"
      api_get "$API_BASE/social-copy-jobs/$job_id" | output
      ;;

    publish)
      local project_id="" clip_id="" account_id="" sub_account=""
      local title="" description="" privacy="" media_type=""
      while [[ $# -gt 0 ]]; do
        case "$1" in
          --project)     project_id="$2"; shift 2 ;;
          --clip)        clip_id="$2"; shift 2 ;;
          --account)     account_id="$2"; shift 2 ;;
          --sub-account) sub_account="$2"; shift 2 ;;
          --title)       title="$2"; shift 2 ;;
          --description) description="$2"; shift 2 ;;
          --privacy)     privacy="$2"; shift 2 ;;
          --media-type)  media_type="$2"; shift 2 ;;
          *) die "post publish: unknown flag '$1'" ;;
        esac
      done
      [[ -n "$project_id" ]] || die "post publish: --project is required"
      [[ -n "$clip_id" ]]    || die "post publish: --clip is required"
      [[ -n "$account_id" ]] || die "post publish: --account is required"
      [[ -n "$title" ]]      || die "post publish: --title is required"
      clip_id=$(clip_suffix "$clip_id" "$project_id")

      local post_detail
      post_detail=$(jq -n --arg t "$title" '{title: $t}')
      [[ -n "$media_type" ]] && post_detail=$(echo "$post_detail" | jq --arg v "$media_type" '. + {mediaType: $v}')
      local custom="{}"
      [[ -n "$description" ]] && custom=$(echo "$custom" | jq --arg v "$description" '. + {description: $v}')
      [[ -n "$privacy" ]]     && custom=$(echo "$custom" | jq --arg v "$privacy" '. + {privacy: $v}')
      [[ "$custom" != "{}" ]] && post_detail=$(echo "$post_detail" | jq --argjson c "$custom" '. + {custom: $c}')

      local payload
      payload=$(jq -n \
        --arg pid "$project_id" --arg cid "$clip_id" --arg aid "$account_id" \
        --argjson pd "$post_detail" \
        '{projectId: $pid, clipId: $cid, postAccountId: $aid, postDetail: $pd}')
      [[ -n "$sub_account" ]] && payload=$(echo "$payload" | jq --arg v "$sub_account" '. + {subAccountId: $v}')

      api_post "$API_BASE/post-tasks" "$payload" | output
      ;;

    schedule)
      local project_id="" clip_id="" account_id="" sub_account="" publish_at=""
      local title="" description="" privacy="" media_type=""
      while [[ $# -gt 0 ]]; do
        case "$1" in
          --project)     project_id="$2"; shift 2 ;;
          --clip)        clip_id="$2"; shift 2 ;;
          --account)     account_id="$2"; shift 2 ;;
          --sub-account) sub_account="$2"; shift 2 ;;
          --at)          publish_at="$2"; shift 2 ;;
          --title)       title="$2"; shift 2 ;;
          --description) description="$2"; shift 2 ;;
          --privacy)     privacy="$2"; shift 2 ;;
          --media-type)  media_type="$2"; shift 2 ;;
          *) die "post schedule: unknown flag '$1'" ;;
        esac
      done
      [[ -n "$project_id" ]] || die "post schedule: --project is required"
      [[ -n "$clip_id" ]]    || die "post schedule: --clip is required"
      [[ -n "$account_id" ]] || die "post schedule: --account is required"
      [[ -n "$title" ]]      || die "post schedule: --title is required"
      [[ -n "$publish_at" ]] || die "post schedule: --at is required (ISO 8601 UTC, e.g. 2026-03-25T14:00:00Z)"
      clip_id=$(clip_suffix "$clip_id" "$project_id")

      local post_detail
      post_detail=$(jq -n --arg t "$title" '{title: $t}')
      [[ -n "$media_type" ]] && post_detail=$(echo "$post_detail" | jq --arg v "$media_type" '. + {mediaType: $v}')
      local custom="{}"
      [[ -n "$description" ]] && custom=$(echo "$custom" | jq --arg v "$description" '. + {description: $v}')
      [[ -n "$privacy" ]]     && custom=$(echo "$custom" | jq --arg v "$privacy" '. + {privacy: $v}')
      [[ "$custom" != "{}" ]] && post_detail=$(echo "$post_detail" | jq --argjson c "$custom" '. + {custom: $c}')

      local payload
      payload=$(jq -n \
        --arg pid "$project_id" --arg cid "$clip_id" --arg aid "$account_id" \
        --arg at "$publish_at" --argjson pd "$post_detail" \
        '{projectId: $pid, clipId: $cid, postAccountId: $aid, publishAt: $at, postDetail: $pd}')
      [[ -n "$sub_account" ]] && payload=$(echo "$payload" | jq --arg v "$sub_account" '. + {subAccountId: $v}')

      api_post "$API_BASE/publish-schedules" "$payload" | output
      ;;

    cancel)
      local schedule_id=""
      while [[ $# -gt 0 ]]; do
        case "$1" in
          --schedule) schedule_id="$2"; shift 2 ;;
          *) die "post cancel: unknown flag '$1'" ;;
        esac
      done
      [[ -n "$schedule_id" ]] || die "post cancel: --schedule is required"
      api_delete "$API_BASE/publish-schedules/$schedule_id" | output
      ;;

    *)
      die "post: unknown subcommand '$subcmd' (use accounts|generate-copy|copy-status|publish|schedule|cancel)"
      ;;
  esac
}

# ── Describe ──────────────────────────────────────────────────────────────

cmd_describe() {
  local project_id="" clip_id="" show_transcript="false" show_layout="false"

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --project)    project_id="$2"; shift 2 ;;
      --clip)       clip_id="$2"; shift 2 ;;
      --transcript) show_transcript="true"; shift ;;
      --layout)     show_layout="true"; shift ;;
      *) die "describe: unknown flag '$1'" ;;
    esac
  done

  [[ -n "$project_id" ]] || die "describe: --project is required"
  [[ -n "$clip_id" ]]    || die "describe: --clip is required"

  local clips_json
  clips_json=$(api_get "$API_BASE/exportable-clips?q=findByProjectId&projectId=$project_id")

  if [[ "$show_transcript" == "true" ]]; then
    echo "$clips_json" | jq --arg cid "$clip_id" \
      '.data[] | select(.curationId == $cid or .id == $cid) | {
        id: .id,
        title: .title,
        transcript: .text
      }'
  fi

  if [[ "$show_layout" == "true" ]]; then
    echo "$clips_json" | jq --arg cid "$clip_id" \
      '.data[] | select(.curationId == $cid or .id == $cid) | {
        id: .id,
        layout: .renderPref.layoutType,
        aspect: .renderPref.layoutAspectRatio
      }'
  fi

  # Default: show both
  if [[ "$show_transcript" == "false" && "$show_layout" == "false" ]]; then
    echo "$clips_json" | jq --arg cid "$clip_id" \
      '.data[] | select(.curationId == $cid or .id == $cid) | {
        id: .id,
        title: .title,
        description: .description,
        transcript: .text,
        hashtags: .hashtags,
        keywords: .clipKeywords,
        duration_sec: ((.durationMs // 0) / 1000 | round),
        durationMs: .durationMs,
        score: .score,
        uriForPreview: .uriForPreview,
        uriForExport: .uriForExport,
        renderAsVideoPreview: .renderAsVideoPreview,
        renderAsVideoFile: .renderAsVideoFile
      }'
  fi
}

# ── Storyboard ────────────────────────────────────────────────────────────

cmd_storyboard() {
  local project_id="" clip_id="" out_file=""

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --project) project_id="$2"; shift 2 ;;
      --clip)    clip_id="$2"; shift 2 ;;
      --output)  out_file="$2"; shift 2 ;;
      *) die "storyboard: unknown flag '$1'" ;;
    esac
  done

  [[ -n "$project_id" ]] || die "storyboard: --project is required"
  [[ -n "$clip_id" ]]    || die "storyboard: --clip is required"
  need ffmpeg "Install via 'brew install ffmpeg' (macOS) or your package manager. storyboard is local-only — needs ffmpeg to render the grid."

  local clips_json
  clips_json=$(api_get "$API_BASE/exportable-clips?q=findByProjectId&projectId=$project_id")

  local preview_url dur_int
  preview_url=$(echo "$clips_json" | jq -r \
    --arg cid "$clip_id" \
    '.data[] | select(.curationId == $cid or .id == $cid) | .uriForPreview // empty' \
    | head -1)
  dur_int=$(echo "$clips_json" | jq -r \
    --arg cid "$clip_id" \
    '.data[] | select(.curationId == $cid or .id == $cid) | ((.durationMs // 0) / 1000 | floor)' \
    | head -1)

  [[ -n "$preview_url" ]] || die "storyboard: no preview video found for clip $clip_id"
  [[ "$dur_int" -gt 0 ]] 2>/dev/null || die "storyboard: could not determine clip duration"

  local tmp_video="/tmp/opusclip-storyboard-${clip_id}.mp4"
  echo "Downloading preview video..." >&2
  curl -sS -o "$tmp_video" "$preview_url"

  # 4 evenly spaced points (center of each quarter)
  local t1=$(( dur_int * 12 / 100 ))
  local t2=$(( dur_int * 37 / 100 ))
  local t3=$(( dur_int * 62 / 100 ))
  local t4=$(( dur_int * 87 / 100 ))

  # Format label: "MM:SS, NN%" (escaped for ffmpeg drawtext filter)
  _sb_label() {
    local s=$1 d=$2
    local pct=$(( s * 100 / d ))
    printf '%02d\\:%02d\\, %d%%' $(( s / 60 )) $(( s % 60 )) "$pct"
  }

  local l1 l2 l3 l4
  l1=$(_sb_label "$t1" "$dur_int")
  l2=$(_sb_label "$t2" "$dur_int")
  l3=$(_sb_label "$t3" "$dur_int")
  l4=$(_sb_label "$t4" "$dur_int")

  # Extract 4 frames, optionally label them, then combine into 2x2 grid.
  # Labels need ffmpeg's drawtext filter (requires libfreetype). On builds
  # without it (some minimal ffmpeg installs), fall back to unlabeled frames
  # instead of failing silently — the grid is still useful for visual review.
  local tmp_dir="/tmp/opusclip-sb-$$"
  mkdir -p "$tmp_dir"

  local can_label="true"
  if ! ffmpeg_has_filter drawtext; then
    can_label="false"
    echo "warning: ffmpeg lacks the drawtext filter — generating an unlabeled storyboard. For time labels, reinstall ffmpeg with libfreetype (e.g. 'brew reinstall ffmpeg' on macOS)." >&2
  fi

  _sb_frame() {
    local ts=$1 label=$2 idx=$3
    if ! ffmpeg -y -ss "$ts" -i "$tmp_video" -frames:v 1 -update 1 -q:v 2 "$tmp_dir/raw${idx}.jpg" >/dev/null 2>&1; then
      die "storyboard: ffmpeg failed to extract frame at ${ts}s from $tmp_video"
    fi
    if [[ "$can_label" == "true" ]]; then
      ffmpeg -y -i "$tmp_dir/raw${idx}.jpg" \
        -vf "drawtext=text='${label}':expansion=none:x=(w-tw)/2:y=h-th-10:fontsize=28:fontcolor=white:borderw=3:bordercolor=black" \
        -frames:v 1 -update 1 -q:v 2 "$tmp_dir/${idx}.jpg" >/dev/null 2>&1 \
        || cp "$tmp_dir/raw${idx}.jpg" "$tmp_dir/${idx}.jpg"
    else
      cp "$tmp_dir/raw${idx}.jpg" "$tmp_dir/${idx}.jpg"
    fi
  }

  _sb_frame "$t1" "$l1" 1
  _sb_frame "$t2" "$l2" 2
  _sb_frame "$t3" "$l3" 3
  _sb_frame "$t4" "$l4" 4

  [[ -z "$out_file" ]] && out_file="/tmp/opusclip-storyboard-${clip_id}.jpg"

  if ! ffmpeg -y \
    -i "$tmp_dir/1.jpg" -i "$tmp_dir/2.jpg" -i "$tmp_dir/3.jpg" -i "$tmp_dir/4.jpg" \
    -filter_complex "xstack=inputs=4:layout=0_0|w0_0|0_h0|w0_h0[out]" \
    -map "[out]" -frames:v 1 -update 1 -q:v 2 "$out_file" >/dev/null 2>&1; then
    rm -rf "$tmp_dir"
    die "storyboard: ffmpeg xstack failed to combine frames into $out_file"
  fi

  rm -rf "$tmp_dir"

  echo "Storyboard: $out_file" >&2

  if command -v open >/dev/null 2>&1; then
    open "$out_file"
  elif command -v xdg-open >/dev/null 2>&1; then
    xdg-open "$out_file"
  fi

  echo "{\"storyboard\": \"$out_file\"}"
}

# ── Trim ──────────────────────────────────────────────────────────────────

cmd_trim() {
  local project_id="" clip_id="" start_sec="" end_sec="" out_file=""

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --project) project_id="$2"; shift 2 ;;
      --clip)    clip_id="$2"; shift 2 ;;
      --start)   start_sec="$2"; shift 2 ;;
      --end)     end_sec="$2"; shift 2 ;;
      --output)  out_file="$2"; shift 2 ;;
      *) die "trim: unknown flag '$1'" ;;
    esac
  done

  [[ -n "$project_id" ]] || die "trim: --project is required"
  [[ -n "$clip_id" ]]    || die "trim: --clip is required"
  [[ -n "$start_sec" ]]  || die "trim: --start is required"
  [[ -n "$end_sec" ]]    || die "trim: --end is required"
  need ffmpeg "Install via 'brew install ffmpeg' (macOS) or your package manager. trim is local — no API call, just an ffmpeg cut. (For server-side trim with captions, use 'opusclip edit-clip trim' instead.)"

  local clips_json
  clips_json=$(api_get "$API_BASE/exportable-clips?q=findByProjectId&projectId=$project_id")

  local preview_url
  preview_url=$(echo "$clips_json" | jq -r \
    --arg cid "$clip_id" \
    '.data[] | select(.curationId == $cid or .id == $cid) | .uriForPreview // empty' \
    | head -1)

  [[ -n "$preview_url" ]] || die "trim: no preview video found for clip $clip_id"

  local tmp_video="/tmp/opusclip-trim-${clip_id}.mp4"
  echo "Downloading preview video..." >&2
  curl -sS -o "$tmp_video" "$preview_url"

  [[ -z "$out_file" ]] && out_file="/tmp/opusclip-trimmed-${clip_id}.mp4"
  ffmpeg -y -i "$tmp_video" -ss "$start_sec" -to "$end_sec" -c copy "$out_file" 2>/dev/null

  echo "Trimmed: $out_file" >&2
  echo "{\"output\": \"$out_file\", \"start\": $start_sec, \"end\": $end_sec}"
}

# ── Edit Clip (server-side re-render) ────────────────────────────────────
#
# One umbrella verb wraps every server-side edit a clip can take. Sub-verbs:
#
#   opusclip edit-clip get             fetch the clip's EditingScript JSON
#   opusclip edit-clip apply           submit a fully-edited script back
#   opusclip edit-clip caption-fix     find/replace caption text
#   opusclip edit-clip caption-replace replace whole caption track from transcript
#   opusclip edit-clip trim            server-side trim (shrink only)
#
# All sub-verbs share the same underlying endpoints:
#   GET  /api/exportable-clips/:clipFullId?include=editingScript
#   POST /api/exportable-clips/:clipFullId/re-render
# The wrapper does the EditingScript walking; the API stays a generic
# passthrough. Future edit ops the web editor adds are reachable through
# `edit-clip apply --script` without any new wrapper.

cmd_edit_clip() {
  local subcmd="${1:-}"; shift 2>/dev/null || true
  case "$subcmd" in
    get)             cmd_edit_clip_get "$@" ;;
    apply)           cmd_edit_clip_apply "$@" ;;
    caption-fix)     cmd_edit_clip_caption_fix "$@" ;;
    caption-replace) cmd_edit_clip_caption_replace "$@" ;;
    censor)          cmd_edit_clip_censor "$@" ;;
    trim)            cmd_edit_clip_trim "$@" ;;
    *)
      die "edit-clip: unknown subcommand '$subcmd' (use get|apply|caption-fix|caption-replace|censor|trim)"
      ;;
  esac
}

# Fetch the EditingScript JSON for a clip
cmd_edit_clip_get() {
  local project_id="" clip_id="" out_file=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --project) project_id="$2"; shift 2 ;;
      --clip)    clip_id="$2"; shift 2 ;;
      --output)  out_file="$2"; shift 2 ;;
      *) die "edit-clip get: unknown flag '$1'" ;;
    esac
  done
  [[ -n "$project_id" ]] || die "edit-clip get: --project is required"
  [[ -n "$clip_id" ]]    || die "edit-clip get: --clip is required"

  local cid
  cid=$(clip_suffix "$clip_id" "$project_id")
  local body
  body=$(api_get "$API_BASE/exportable-clips/$project_id.$cid?include=editingScript")
  if [[ -n "$out_file" ]]; then
    echo "$body" | jq '.editingScript' > "$out_file"
    echo "Saved EditingScript to: $out_file" >&2
    echo "{\"output\": \"$out_file\"}"
  else
    echo "$body" | jq '.editingScript'
  fi
}

# Submit a fully-edited EditingScript back for re-render
cmd_edit_clip_apply() {
  local project_id="" clip_id="" script_file=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --project) project_id="$2"; shift 2 ;;
      --clip)    clip_id="$2"; shift 2 ;;
      --script)  script_file="$2"; shift 2 ;;
      *) die "edit-clip apply: unknown flag '$1'" ;;
    esac
  done
  [[ -n "$project_id" ]]   || die "edit-clip apply: --project is required"
  [[ -n "$clip_id" ]]      || die "edit-clip apply: --clip is required"
  [[ -n "$script_file" ]]  || die "edit-clip apply: --script <file> is required"
  [[ -f "$script_file" ]]  || die "edit-clip apply: file '$script_file' not found"

  local cid
  cid=$(clip_suffix "$clip_id" "$project_id")
  local body
  body=$(jq -nc --slurpfile s "$script_file" '{editingScript: $s[0]}')
  api_post "$API_BASE/exportable-clips/$project_id.$cid/re-render" "$body" | output
}

# Fix typos / phrasing: find/replace text across every caption TextElement.
#
# Caption tracks store each word as its own TextElement (so per-word timing
# can drive the karaoke-style highlight). Two modes:
#
#   * single-word --find  → regex gsub on every textElement.text (back-compat;
#     supports in-word matches like --find "haha" --replace "ha")
#   * multi-word --find   → walk the textElements array, replace consecutive
#     token sequences 1:1. --replace must have the same word count, since
#     each replacement token inherits the matched slot's timing. For
#     different-length rewrites, fall back to caption-replace / apply.
cmd_edit_clip_caption_fix() {
  local project_id="" clip_id="" find_text="" replace_text="" ignore_case="false"
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --project)     project_id="$2"; shift 2 ;;
      --clip)        clip_id="$2"; shift 2 ;;
      --find)        find_text="$2"; shift 2 ;;
      --replace)     replace_text="$2"; shift 2 ;;
      --ignore-case) ignore_case="true"; shift ;;
      *) die "edit-clip caption-fix: unknown flag '$1'" ;;
    esac
  done
  [[ -n "$project_id" ]]   || die "edit-clip caption-fix: --project is required"
  [[ -n "$clip_id" ]]      || die "edit-clip caption-fix: --clip is required"
  [[ -n "$find_text" ]]    || die "edit-clip caption-fix: --find is required"
  [[ -n "$replace_text" ]] || die "edit-clip caption-fix: --replace is required"

  local cid
  cid=$(clip_suffix "$clip_id" "$project_id")
  local script_resp
  script_resp=$(api_get "$API_BASE/exportable-clips/$project_id.$cid?include=editingScript")

  # Tokenize on whitespace runs to decide single- vs multi-word mode.
  local find_tokens replace_tokens n_find n_repl
  find_tokens=$(jq -nc --arg s "$find_text"    '$s | split("\\s+"; null) | map(select(. != ""))')
  replace_tokens=$(jq -nc --arg s "$replace_text" '$s | split("\\s+"; null) | map(select(. != ""))')
  n_find=$(jq -n --argjson t "$find_tokens"    '$t | length')
  n_repl=$(jq -n --argjson t "$replace_tokens" '$t | length')

  local edited match_count body

  if [[ "$n_find" -le 1 ]]; then
    # Single-token path: regex gsub on each textElement.text (back-compat).
    local jq_flags='""'
    [[ "$ignore_case" == "true" ]] && jq_flags='"i"'

    # Count matches BEFORE replacement so we can short-circuit on zero (and
    # avoid spending render credits on a no-op POST).
    match_count=$(echo "$script_resp" | jq --arg find "$find_text" --argjson flags "$jq_flags" '
      [.editingScript.tracks[]? | select(.trackType=="CaptionTrack")
        | .sections[]?.segments[]?.content.textElements[]?.text
        | select(test($find; $flags))] | length
    ')

    if [[ "$match_count" == "0" ]]; then
      jq -nc --arg find "$find_text" --arg repl "$replace_text" \
        '{message: "no caption text matched; nothing to re-render", find: $find, replace: $repl, matchCount: 0, mode: "single-token"}'
      return 0
    fi

    edited=$(echo "$script_resp" | jq -c --arg find "$find_text" --arg repl "$replace_text" --argjson flags "$jq_flags" '
      .editingScript
      | (.tracks |= map(
          if .trackType == "CaptionTrack" then
            .sections |= map(.segments |= map(
              .content.textElements |= map(.text |= gsub($find; $repl; $flags))
            ))
          else . end
        ))
    ')
  else
    # Multi-token path: walk consecutive textElements, replace 1:1.
    [[ "$n_find" == "$n_repl" ]] || die \
"edit-clip caption-fix: multi-word --find has $n_find tokens but --replace has $n_repl.
Captions are stored per-word with per-token timing, so multi-word replacements must
be 1:1. For a different-length rewrite use:
  opusclip edit-clip caption-replace --project P --clip C --transcript <file>
  opusclip edit-clip apply           --project P --clip C --script    <file>"

    # Flatten every CaptionTrack's textElements into a single ordered list
    # tagged with (ti, si, gi, ei) provenance, run sequence search on the flat
    # list, then write the 1:1 replacements back to their original positions.
    # This is the only way to match phrases that cross segment boundaries —
    # caption segments are typically 1-5 tokens, so most natural multi-word
    # finds (e.g. "Vault Dweller") will span at least one boundary.
    local edited_full
    edited_full=$(echo "$script_resp" | jq -c \
      --argjson ft "$find_tokens" \
      --argjson rt "$replace_tokens" \
      --argjson ic "$(jq -n --argjson v "$([[ "$ignore_case" == "true" ]] && echo true || echo false)" '$v')" '
      def flatten_caption_tokens:
        [ range(0; (.tracks | length)) as $ti
          | .tracks[$ti] as $t
          | select($t.trackType == "CaptionTrack")
          | range(0; ($t.sections | length)) as $si
          | $t.sections[$si] as $sec
          | range(0; ($sec.segments | length)) as $gi
          | $sec.segments[$gi] as $seg
          | range(0; ($seg.content.textElements | length)) as $ei
          | { ti: $ti, si: $si, gi: $gi, ei: $ei,
              text: $seg.content.textElements[$ei].text }
        ];

      def find_seq_starts($flat; $ft; $ic):
        ($ft | length) as $n
        | ($flat | length) as $len
        | (if $ic then ($ft | map(ascii_downcase)) else $ft end) as $needle
        | (if $len < $n then [] else
            [range(0; $len - $n + 1)
              | . as $i
              | ([range(0; $n) as $j | $flat[$i + $j].text]
                 | if $ic then map(ascii_downcase) else . end) as $window
              | select($window == $needle)
              | $i]
          end) as $raw_starts
        | (reduce $raw_starts[] as $i ({last: 0, hits: []};
            if $i >= .last then .hits += [$i] | .last = $i + $n else . end)).hits;

      .editingScript as $script
      | ($script | flatten_caption_tokens) as $flat
      | find_seq_starts($flat; $ft; $ic) as $starts
      | ($starts | length) as $matched
      | (reduce $starts[] as $i ($script;
          reduce range(0; ($ft | length)) as $j (.;
            $flat[$i + $j] as $loc
            | .tracks[$loc.ti].sections[$loc.si].segments[$loc.gi].content.textElements[$loc.ei].text = $rt[$j]
          ))) as $new_script
      | {script: $new_script, matched: $matched}
    ')
    edited=$(echo "$edited_full" | jq -c '.script')
    match_count=$(echo "$edited_full" | jq -r '.matched')

    if [[ "$match_count" == "0" ]]; then
      jq -nc --arg find "$find_text" --arg repl "$replace_text" \
        '{message: "no caption text matched; nothing to re-render", find: $find, replace: $repl, matchCount: 0, mode: "multi-token"}'
      return 0
    fi
  fi

  body=$(jq -nc --argjson s "$edited" '{editingScript: $s}')
  local mode_label
  [[ "$n_find" -le 1 ]] && mode_label="single-token" || mode_label="multi-token"
  # Only overlay metadata on success (`.jobId` present). Error bodies pass
  # through unmodified so callers can distinguish actual failures.
  api_post "$API_BASE/exportable-clips/$project_id.$cid/re-render" "$body" \
    | jq --arg find "$find_text" --arg repl "$replace_text" --argjson matched "$match_count" --arg mode "$mode_label" '
        if .jobId then
          . + {find: $find, replace: $repl, matchCount: $matched, mode: $mode}
        else . end
      '
}

# Replace entire caption track from a transcript JSON file
# File shape: { "segments": [{ "text": "...", "startMs": 0, "endMs": 1500,
#                              "words"?: [{ "word": "...", "startMs": 0, "endMs": 500 }] }] }
cmd_edit_clip_caption_replace() {
  local project_id="" clip_id="" transcript_file=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --project)    project_id="$2"; shift 2 ;;
      --clip)       clip_id="$2"; shift 2 ;;
      --transcript) transcript_file="$2"; shift 2 ;;
      *) die "edit-clip caption-replace: unknown flag '$1'" ;;
    esac
  done
  [[ -n "$project_id" ]]       || die "edit-clip caption-replace: --project is required"
  [[ -n "$clip_id" ]]          || die "edit-clip caption-replace: --clip is required"
  [[ -n "$transcript_file" ]]  || die "edit-clip caption-replace: --transcript <file> is required"
  [[ -f "$transcript_file" ]]  || die "edit-clip caption-replace: file '$transcript_file' not found"

  local cid
  cid=$(clip_suffix "$clip_id" "$project_id")
  local script_resp
  script_resp=$(api_get "$API_BASE/exportable-clips/$project_id.$cid?include=editingScript")

  # Wholesale-replace the CaptionTrack: collapse to one section + one segment
  # carrying every word from the transcript. Preserve the existing section
  # timing so the clip duration doesn't change. Drop the other (now-orphan)
  # sections — otherwise leftover textElements from those sections appear
  # alongside the new transcript in the rendered output.
  local edited
  edited=$(jq -nc \
    --argjson script "$script_resp" \
    --slurpfile tr "$transcript_file" '
      ($tr[0] // {}) as $t
      | ($t.segments // []) as $segs
      | ($segs | to_entries | map(
          .key as $i | .value as $seg
          | (($seg.words // [{word: $seg.text, startMs: $seg.startMs, endMs: $seg.endMs}])
             | to_entries | map(
              .key as $j | .value as $w
              | {
                  id: "caption-replace-\($i)-\($j)",
                  text: $w.word,
                  color: 0,
                  duration: { type: "TS", sO: 0, eO: (($w.endMs // 0) - ($w.startMs // 0)) },
                  timeline: { in: ($w.startMs // 0), out: ($w.endMs // 0) }
                }
            ))
        ) | add // []) as $newElements
      | $script.editingScript
      | .tracks |= map(
          if .trackType == "CaptionTrack" then
            .sections = [
              (.sections[0] // {sectionTimeline: {in: 0, out: 0}, sectionDuration: {type: "TS", sO: 0, eO: 0}})
              | .segments = [{
                  id: ((.segments[0].id) // "caption-replace-segment"),
                  content: { textElements: $newElements }
                }]
            ]
          else . end
        )
    ')

  local body
  body=$(jq -nc --argjson s "$edited" '{editingScript: $s}')
  api_post "$API_BASE/exportable-clips/$project_id.$cid/re-render" "$body" | output
}

# Profanity censor: server-side dictionary-based caption mutation + beep audio.
# Hits the existing /api/censor-jobs endpoint (predates the unified /re-render
# primitive). Under the hood it does the same engine save-and-render call;
# the CLI just routes to a different controller. Poll status via
# `opusclip describe --project P --clip C` (renderAsVideoFile.pending).
cmd_edit_clip_censor() {
  local project_id="" clip_id="" beep="false"
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --project) project_id="$2"; shift 2 ;;
      --clip)    clip_id="$2"; shift 2 ;;
      --beep)    beep="true"; shift ;;
      *) die "edit-clip censor: unknown flag '$1'" ;;
    esac
  done
  [[ -n "$project_id" ]] || die "edit-clip censor: --project is required"
  [[ -n "$clip_id" ]]    || die "edit-clip censor: --clip is required"
  clip_id=$(clip_suffix "$clip_id" "$project_id")
  local payload
  payload=$(jq -n --arg pid "$project_id" --arg cid "$clip_id" --argjson beep "$beep" \
    '{projectId: $pid, clipId: $cid, options: {beepSound: $beep}}')
  api_post "$API_BASE/censor-jobs" "$payload" | output
}

# Server-side trim: shrink the clip via re-render (charged; opposite of local `trim`)
cmd_edit_clip_trim() {
  local project_id="" clip_id="" start_ms="" end_ms=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --project)  project_id="$2"; shift 2 ;;
      --clip)     clip_id="$2"; shift 2 ;;
      --start-ms) start_ms="$2"; shift 2 ;;
      --end-ms)   end_ms="$2"; shift 2 ;;
      --start)    start_ms="$(awk -v v="$2" 'BEGIN{printf "%d", v*1000}')"; shift 2 ;;
      --end)      end_ms="$(awk -v v="$2" 'BEGIN{printf "%d", v*1000}')"; shift 2 ;;
      *) die "edit-clip trim: unknown flag '$1'" ;;
    esac
  done
  [[ -n "$project_id" ]] || die "edit-clip trim: --project is required"
  [[ -n "$clip_id" ]]    || die "edit-clip trim: --clip is required"
  [[ -n "$start_ms" ]]   || die "edit-clip trim: --start (seconds) or --start-ms is required"
  [[ -n "$end_ms" ]]     || die "edit-clip trim: --end (seconds) or --end-ms is required"
  [[ "$end_ms" -gt "$start_ms" ]] || die "edit-clip trim: --end must be greater than --start"

  # v1 ships shrink only. Extending past source duration is an engine no-op
  # (the EditingScript sectionTimeline gets clamped silently, the re-render
  # publishes the original mp4, and the caller is left wondering why nothing
  # changed). Clamp --end to the actual durationMs so callers get a
  # deterministic shrink — and surface the clamp in the response.
  local cid
  cid=$(clip_suffix "$clip_id" "$project_id")
  local clip_meta
  clip_meta=$(api_get "$API_BASE/exportable-clips?q=findByProjectId&projectId=$project_id")
  local current_duration
  current_duration=$(echo "$clip_meta" | jq -r --arg cid "$cid" '
    .data[] | select(.curationId == $cid or .id == $cid) | .durationMs' | head -1)
  local clamp_note=""
  local requested_end_ms="$end_ms"
  if [[ -n "$current_duration" && "$current_duration" != "null" && "$end_ms" -gt "$current_duration" ]]; then
    clamp_note="--end ($requested_end_ms ms) > current clip duration ($current_duration ms); clamped to $current_duration ms"
    echo "note: $clamp_note" >&2
    end_ms="$current_duration"
  fi
  [[ "$end_ms" -gt "$start_ms" ]] || die "edit-clip trim: after clamp, --end ($end_ms) must be greater than --start ($start_ms)"

  local script_resp
  script_resp=$(api_get "$API_BASE/exportable-clips/$project_id.$cid?include=editingScript")

  # For every timed track section, set sectionTimeline + sectionDuration to the new window.
  local edited
  edited=$(echo "$script_resp" | jq -c --argjson s "$start_ms" --argjson e "$end_ms" '
    .editingScript
    | .tracks |= map(
        if ((.sections // []) | length) > 0 and (.sections[0].sectionTimeline // null) != null then
          .sections[0].sectionTimeline.in  = $s
          | .sections[0].sectionTimeline.out = $e
          | .sections[0].sectionDuration.sO = $s
          | .sections[0].sectionDuration.eO = $e
        else . end
      )
  ')

  local body
  body=$(jq -nc --argjson s "$edited" '{editingScript: $s}')
  # Only overlay trim metadata on success responses (those have `.jobId`).
  # Error bodies (e.g. 4xx/5xx JSON) pass through unmodified — overlaying
  # startMs/endMs/durationMs onto a 413 made past failures look half-successful.
  api_post "$API_BASE/exportable-clips/$project_id.$cid/re-render" "$body" \
    | jq --argjson s "$start_ms" --argjson e "$end_ms" --argjson req "$requested_end_ms" --arg note "$clamp_note" '
        if .jobId then
          . + {startMs: $s, endMs: $e, durationMs: ($e - $s)}
            + (if $note != "" then {requestedEndMs: $req, clampedEndMs: $e, note: $note} else {} end)
        else . end
      '
}

# ── Preview ──────────────────────────────────────────────────────────────

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMPLATE_DIR="$SCRIPT_DIR/../templates"

cmd_preview() {
  local project_id="" collection_id="" out_file=""

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --project)    project_id="$2"; shift 2 ;;
      --collection) collection_id="$2"; shift 2 ;;
      --output)     out_file="$2"; shift 2 ;;
      *) die "preview: unknown flag '$1'" ;;
    esac
  done

  # Fetch clips
  local url
  if [[ -n "$project_id" ]]; then
    url="$API_BASE/exportable-clips?q=findByProjectId&projectId=$project_id"
  elif [[ -n "$collection_id" ]]; then
    url="$API_BASE/exportable-clips?q=findByCollectionId&collectionId=$collection_id"
  else
    die "preview: --project or --collection is required"
  fi

  local clips_json
  clips_json=$(api_get "$url")

  local clip_count
  clip_count=$(echo "$clips_json" | jq '.data | length')

  if [[ "$clip_count" -eq 0 ]]; then
    die "preview: no clips found (project may still be processing)"
  fi

  # Get project title from first clip
  local project_title
  project_title=$(echo "$clips_json" | jq -r '.data[0].title // "OpusClip Preview"')

  # Build clip cards HTML
  local cards=""
  cards=$(echo "$clips_json" | jq -r '.data | sort_by(-.score) | to_entries[] | @json' | while IFS= read -r entry; do
    local idx score title desc tags duration preview_url
    local hook_s coherence_s connection_s trend_s

    idx=$(echo "$entry" | jq -r '.key')
    score=$(echo "$entry" | jq -r '.value.score // "—"')
    title=$(echo "$entry" | jq -r '.value.title // "Untitled Clip"')
    desc=$(echo "$entry" | jq -r '.value.description // ""')
    tags=$(echo "$entry" | jq -r '.value.hashtags // ""')
    duration=$(echo "$entry" | jq -r '((.value.durationMs // 0) / 1000 | floor) as $s | "\($s / 60 | floor):\(($s % 60) | tostring | if length < 2 then "0" + . else . end)"')
    preview_url=$(echo "$entry" | jq -r '.value.uriForPreview // ""')
    hook_s=$(echo "$entry" | jq -r '.value.judgeResult.hookScore // "—"')
    coherence_s=$(echo "$entry" | jq -r '.value.judgeResult.coherenceScore // "—"')
    connection_s=$(echo "$entry" | jq -r '.value.judgeResult.connectionScore // "—"')
    trend_s=$(echo "$entry" | jq -r '.value.judgeResult.trendScore // "—"')

    cat <<CARD
    <div class="clip">
      <video controls preload="metadata" src="$preview_url"></video>
      <div class="clip-info">
        <span class="clip-rank">#$((idx + 1))</span>
        <span class="clip-score">Score: $score</span>
        <span class="clip-duration">$duration</span>
        <div class="clip-title">$title</div>
        <div class="clip-desc">$desc</div>
        <div class="clip-tags">$tags</div>
        <div class="scores">
          <span>Hook: $hook_s</span>
          <span>Coherence: $coherence_s</span>
          <span>Connection: $connection_s</span>
          <span>Trend: $trend_s</span>
        </div>
      </div>
    </div>
CARD
  done)

  # Load template and substitute
  local template_file="$TEMPLATE_DIR/preview.html"
  [[ -f "$template_file" ]] || die "preview: template not found at $template_file"

  local html
  html=$(cat "$template_file")
  html="${html//\{\{PROJECT_TITLE\}\}/$project_title}"
  html="${html//\{\{CLIP_COUNT\}\}/$clip_count}"
  html="${html//\{\{CLIP_CARDS\}\}/$cards}"

  # Write output
  if [[ -z "$out_file" ]]; then
    out_file="/tmp/opusclip-preview-${project_id:-${collection_id}}.html"
  fi

  echo "$html" > "$out_file"
  echo "Preview written to: $out_file" >&2

  # Open in browser on macOS
  if command -v open >/dev/null 2>&1; then
    open "$out_file"
  elif command -v xdg-open >/dev/null 2>&1; then
    xdg-open "$out_file"
  fi

  echo "{\"preview\": \"$out_file\", \"clips\": $clip_count}"
}

# ── Usage ────────────────────────────────────────────────────────────────────

usage() {
  cat <<EOF
opusclip $VERSION — OpusClip API CLI

USAGE
  opusclip <command> [options]

COMMANDS
  submit            Submit a video for clipping (alias: create-project)
  list              List clips for a project or collection (alias: get-clips)
  describe          Get clip details — transcript, layout info
  storyboard        Generate 2x2 frame grid image (requires ffmpeg)
  trim              Trim a clip locally (requires ffmpeg, no API call)
  edit-clip         Server-side clip edits via re-render (charged):
                    get | apply | caption-fix | caption-replace | censor | trim
  preview           Generate HTML preview page and open in browser
  share             Update project visibility (alias: share-project)
  templates         List available brand templates
  upload            Upload a local video file and create a clip project
  collections       Manage collections (list|create|delete|export|add-clip|remove-clip)
  post              Social posting (accounts|generate-copy|copy-status|publish|schedule|cancel)
  help              Show this help message

ENVIRONMENT
  OPUSCLIP_API_KEY        Required. Your API key from https://clip.opus.pro/dashboard
  OPUSCLIP_API_URL        Override API base URL (default: https://api.opus.pro/api)
  OPUSCLIP_EXTRA_HEADERS  Semicolon-separated extra HTTP headers (e.g. "X-Foo: bar;X-Baz: qux")

EXAMPLES
  # Submit a video for clipping from a YouTube URL.
  # --durations is required in practice (API rejects payloads without curationPref.clipDurations).
  opusclip submit --url "https://youtube.com/watch?v=..." --durations "30,60,90" --aspect portrait

  # Submit with ClipAnything model and custom prompt
  opusclip submit --url "https://youtube.com/watch?v=..." --durations "30,60,90" \\
    --model ClipAnything --prompt "Find the funniest moments"

  # List clips from a project
  opusclip list --project PROJECT_ID

  # Describe a clip (transcript, layout)
  opusclip describe --project PROJECT_ID --clip CLIP_ID
  opusclip describe --project PROJECT_ID --clip CLIP_ID --transcript

  # Generate storyboard image from a clip
  opusclip storyboard --project PROJECT_ID --clip CLIP_ID

  # Trim a clip locally (ffmpeg, no API call)
  opusclip trim --project PROJECT_ID --clip CLIP_ID --start 5 --end 25

  # Server-side edits (charged: re-render via API; see references/editing-script.md)
  #
  # Fix a typo in the captions:
  opusclip edit-clip caption-fix --project PROJECT_ID --clip CLIP_ID \\
    --find "prooduct" --replace "product"
  #
  # Replace the whole caption track from a transcript file:
  opusclip edit-clip caption-replace --project PROJECT_ID --clip CLIP_ID \\
    --transcript captions.json
  #
  # Trim and re-render (shrink only; extend is unverified at the engine):
  opusclip edit-clip trim --project PROJECT_ID --clip CLIP_ID --start 0 --end 15
  #
  # Power-user round-trip — fetch, edit locally, send back:
  opusclip edit-clip get --project PROJECT_ID --clip CLIP_ID --output script.json
  # ...edit script.json...
  opusclip edit-clip apply --project PROJECT_ID --clip CLIP_ID --script script.json

  # Upload a local video and clip it
  opusclip upload --file video.mp4 --title "My Video" --durations "30,60"

  # List brand templates
  opusclip templates

  # Share a project publicly
  opusclip share --project PROJECT_ID --visibility PUBLIC

  # Create and manage a collection
  opusclip collections create --name "Best Clips"
  opusclip collections add-clip --id COLLECTION_ID --content-id PROJECT_ID.CLIP_ID
  opusclip collections export --id COLLECTION_ID

  # Censor profanity in a clip — dictionary-based; --beep adds a sound effect
  opusclip edit-clip censor --project PROJECT_ID --clip CLIP_ID --beep
  # status via 'opusclip describe' (renderAsVideoFile.pending flips false when ready)

  # Social posting — list accounts, publish, schedule
  opusclip post accounts
  opusclip post generate-copy --project PID --clip CID --account AID --prompt "witty tone"
  opusclip post copy-status --job JOB_ID
  opusclip post publish --project PID --clip CID --account AID --title "My Post"
  opusclip post schedule --project PID --clip CID --account AID --title "My Post" --at 2026-03-25T14:00:00Z
  opusclip post cancel --schedule SCHEDULE_ID

For full API docs: https://help.opus.pro/api-reference/overview
EOF
}

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

main() {
  # Extract global flags
  local args=()
  for arg in "$@"; do
    case "$arg" in
      *)     args+=("$arg") ;;
    esac
  done

  set -- "${args[@]+"${args[@]}"}"

  local cmd="${1:-help}"
  shift 2>/dev/null || true

  case "$cmd" in
    # Primary names
    submit)         cmd_create_project "$@" ;;
    list)           cmd_get_clips "$@" ;;
    share)          cmd_share_project "$@" ;;
    describe)       cmd_describe "$@" ;;

    storyboard)     cmd_storyboard "$@" ;;
    trim)           cmd_trim "$@" ;;

    # Server-side editing (re-renders the clip; charged)
    edit-clip)      cmd_edit_clip "$@" ;;

    # Aliases (backwards compat)
    create-project) cmd_create_project "$@" ;;
    get-clips)      cmd_get_clips "$@" ;;
    share-project)  cmd_share_project "$@" ;;

    preview)        cmd_preview "$@" ;;
    templates)      cmd_templates "$@" ;;
    upload)         cmd_upload "$@" ;;
    collections)    cmd_collections "$@" ;;
    post)           cmd_post "$@" ;;
    help|--help|-h) usage ;;
    version|--version|-v) echo "opusclip $VERSION" ;;
    *) die "unknown command '$cmd'. Run 'opusclip help' for usage." ;;
  esac
}

main "$@"
