#!/usr/bin/env python3
"""
Agent Notifier — production-friendly push notification CLI for AI agents.

Sends notifications to the Agent Notifier service (POST /api/v1/messages),
the Pushover-class push platform purpose-built for agents. The CLI mirrors
the public API while keeping customer machines private by default.

Quick usage:
    notify.py "Build finished"
    notify.py "Tests failed" --title "CI" --priority 1 --type alert
    notify.py "Deploying..." --progress 0.4
    notify.py "Review PR" --url https://example.com --url-title "Open PR"
    notify.py "Need input" --button approve --button reject --button defer
    notify.py "See screenshot" --file-path ./screenshot.png
    notify.py "Done" --project my-repo --metadata branch=main --metadata commit=abc123

Authentication:
    No shared API key is embedded. Provide an account API key using one of:
      1. AGENT_NOTIFIER_API_KEY in the environment
      2. AGENT_NOTIFIER_API_KEY in .env.local or .env in the current directory
      3. ~/.config/agent-notifier/config.json (or --config)
      4. --api-key for one-off use (less private: shell history/process list)

Config example:
    notify.py --save-config --api-key an_key_REPLACE_WITH_YOUR_KEY --project my-repo

Project name:
    If --project is omitted, the CLI checks AGENT_NOTIFIER_PROJECT, local env
    files, config, then auto-detects the current git repo name. It falls back
    to the current directory name.

Privacy defaults:
    --file-path uploads bytes to /api/v1/media, but does not send the local
    absolute path in the message payload. Use --reference-file-path for
    metadata-only path context, or --include-local-paths to explicitly send
    upload paths as file_paths metadata.
"""

from __future__ import annotations

import argparse
import json
import mimetypes
import os
import random
import re
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from dataclasses import dataclass
from email.utils import parsedate_to_datetime
from pathlib import Path
from typing import Any

__version__ = "0.5.0"

DEFAULT_BASE_URL = "https://notifier.aicrew.in"
DEFAULT_API_PATH = "/api/v1"
VALID_TYPES = ("alert", "progress", "session_start", "session_end", "url", "file")
VALID_PRIORITIES = (-1, 0, 1, 2)
VALID_COLORS = ("red", "orange", "amber", "lime", "green", "teal", "cyan", "blue", "purple", "pink")
MAX_BUTTONS = 4
MAX_BUTTON_LABEL = 64
MAX_TITLE = 256
MAX_MESSAGE = 4096
MAX_MEDIA_UPLOAD_BYTES = 25 << 20
MAX_METADATA_ENTRIES = 64
MAX_METADATA_KEY = 64
MAX_FILE_PATHS = 12
DEFAULT_TTS_ENGINE = "kokoro"
DEFAULT_TTS_VOICE = "af_bella"
DEFAULT_TTS_SPEED = 1.0
MIN_TTS_SPEED = 0.5
MAX_TTS_SPEED = 2.0
MAX_TTS_TEXT_CHARS = 12_000
TTS_VOICE_RE = re.compile(r"^[a-z]{2}_[a-z0-9_]{1,48}$")
TRANSIENT_STATUSES = {408, 425, 429, 500, 502, 503, 504}
USER_AGENT = f"AgentNotifierCLI/{__version__}"


class NotifyError(Exception):
    """User-facing CLI error."""


class ButtonAction(argparse.Action):
    """Append one or many button labels while preserving argument order."""

    def __call__(self, parser, namespace, values, option_string=None):  # type: ignore[override]
        current = getattr(namespace, self.dest, None)
        if current is None:
            current = []
        if isinstance(values, list):
            current.extend(values)
        else:
            current.append(values)
        setattr(namespace, self.dest, current)


@dataclass
class HTTPResult:
    status: int
    body: dict[str, Any] | str
    headers: dict[str, str]


def default_config_path() -> Path:
    raw = os.environ.get("AGENT_NOTIFIER_CONFIG", "").strip()
    if raw:
        return Path(raw).expanduser()
    return Path.home() / ".config" / "agent-notifier" / "config.json"


def load_config(path: Path) -> dict[str, str]:
    if not path.is_file():
        return {}
    text = path.read_text(encoding="utf-8")
    if not text.strip():
        return {}
    try:
        raw = json.loads(text)
    except (OSError, json.JSONDecodeError) as exc:
        raise NotifyError(f"could not read config {path}: {exc}") from exc
    if not isinstance(raw, dict):
        raise NotifyError(f"config {path} must contain a JSON object")
    return {str(k): str(v) for k, v in raw.items() if v is not None}


def load_env_file(path: Path) -> dict[str, str]:
    if not path.is_file():
        return {}
    values: dict[str, str] = {}
    try:
        lines = path.read_text(encoding="utf-8").splitlines()
    except OSError:
        return {}
    for line in lines:
        stripped = line.strip()
        if not stripped or stripped.startswith("#"):
            continue
        if stripped.startswith("export "):
            stripped = stripped[len("export ") :].strip()
        key, sep, value = stripped.partition("=")
        if not sep:
            continue
        key = key.strip()
        value = value.strip().strip('"').strip("'")
        if key.startswith("AGENT_NOTIFIER_") and key not in values:
            values[key] = value
    return values


def load_local_env() -> dict[str, str]:
    explicit = os.environ.get("AGENT_NOTIFIER_ENV_FILE", "").strip()
    candidates = [Path(explicit).expanduser()] if explicit else [Path.cwd() / ".env.local", Path.cwd() / ".env"]
    merged: dict[str, str] = {}
    for path in candidates:
        merged.update({k: v for k, v in load_env_file(path).items() if k not in merged})
    return merged


def pick_setting(*values: Any, default: str | None = None) -> str | None:
    for value in values:
        if value is None:
            continue
        text = str(value).strip()
        if text:
            return text
    return default


def save_config(path: Path, values: dict[str, str]) -> None:
    existing = load_config(path)
    for key, value in values.items():
        if value:
            existing[key] = value
    if not existing.get("api_key"):
        raise NotifyError("--save-config requires --api-key or AGENT_NOTIFIER_API_KEY")

    path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
    tmp = path.with_name(path.name + ".tmp")
    tmp.write_text(json.dumps(existing, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    os.chmod(tmp, 0o600)
    tmp.replace(path)
    os.chmod(path, 0o600)


def detect_project() -> str:
    """Return current repo name, or cwd basename as fallback."""
    try:
        root = subprocess.run(
            ["git", "rev-parse", "--show-toplevel"],
            check=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            text=True,
            timeout=1.0,
        ).stdout.strip()
        if root:
            return Path(root).name
    except (subprocess.SubprocessError, FileNotFoundError, OSError):
        pass
    return Path.cwd().name or "unknown"


def parse_metadata(items: list[str]) -> dict[str, str]:
    """Parse `key=value` pairs into a flat str->str map."""
    md: dict[str, str] = {}
    for item in items:
        if "=" not in item:
            raise NotifyError(f"--metadata expects key=value, got: {item!r}")
        key, _, value = item.partition("=")
        key = key.strip()
        if not key:
            raise NotifyError(f"--metadata key cannot be empty: {item!r}")
        if len(key) > MAX_METADATA_KEY:
            raise NotifyError(f"--metadata key is too long: {key!r}")
        if key in md:
            raise NotifyError(f"duplicate --metadata key: {key}")
        md[key] = value
    if len(md) > MAX_METADATA_ENTRIES:
        raise NotifyError(f"metadata: maximum {MAX_METADATA_ENTRIES} entries")
    return md


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="notify.py",
        description="Send a push notification via Agent Notifier (POST /api/v1/messages).",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
        allow_abbrev=False,
    )
    p.add_argument("message", nargs="?", default="", help="Notification body (max 4096 chars)")
    p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
    p.add_argument("--title", "-t", default=None, help="Push title (max 256 chars). Defaults to project name when omitted.")
    p.add_argument("--type", choices=VALID_TYPES, default=None, help="Notification type (default: alert, progress when --progress is set, file when --file-path is set)")
    p.add_argument("--priority", "-p", type=int, choices=VALID_PRIORITIES, default=0, help="-1=silent, 0=normal, 1=high (bypass DND), 2=critical (default: 0)")
    p.add_argument("--project", default=None, help="Project name (default: env/config/current git repo)")
    p.add_argument("--session-id", default=None, help="Existing server session UUID to attach to")
    p.add_argument("--client-event-id", default=None, help="UUID idempotency key for retries/replays (default: generated per invocation)")
    p.add_argument("--no-client-event-id", action="store_true", help="Do not send a client_event_id; disables safe send retries")
    p.add_argument("--check-status", metavar="ID", default=None, help="Fetch status for a previously accepted notification ID and exit")
    p.add_argument("--progress", type=float, default=None, help="Progress 0.0–1.0 (sets default type=progress)")
    p.add_argument("--image-url", default=None, help="Deprecated HTTPS image URL; prefer --file-path or POST /media attachment")
    p.add_argument("--as-audio", action="store_true", help="Ask the API to generate speech audio asynchronously from message text or the first Markdown/text --file-path")
    p.add_argument("--voice", "--tts-voice", dest="voice", default=None, help=f"TTS voice id when --as-audio is set (default: {DEFAULT_TTS_VOICE})")
    p.add_argument("--voice-engine", "--tts-engine", dest="voice_engine", default=None, help=f"TTS engine when --as-audio is set (default: {DEFAULT_TTS_ENGINE})")
    p.add_argument("--speed", "--tts-speed", dest="speed", type=float, default=None, help=f"TTS speech speed {MIN_TTS_SPEED}–{MAX_TTS_SPEED} when --as-audio is set (default: {DEFAULT_TTS_SPEED})")
    p.add_argument("--rewrite-for-tts", action="store_true", help="Request server-side text rewriting before TTS (currently an opt-in future flag)")
    p.add_argument("--url", default=None, help="Clickable HTTP(S) URL")
    p.add_argument("--url-title", default=None, help="Display text for --url")
    p.add_argument("--button", "-b", action=ButtonAction, default=[], dest="buttons", help="Action button label (repeat up to 4)")
    p.add_argument("--buttons", nargs="+", action=ButtonAction, default=[], dest="buttons", help="Compatibility alias for multiple action button labels; prefer repeated --button")
    p.add_argument("--sound", default=None, help="Push sound name (default: server default)")
    p.add_argument("--ttl", type=int, default=None, help="TTL seconds (0 = immediate-only)")
    p.add_argument("--device", default=None, help="Target a specific device by name")
    p.add_argument("--color", choices=VALID_COLORS, default=None, help="Notification background color (fixed accessible palette; alerts default orange)")
    p.add_argument("--metadata", "-m", action="append", default=[], help="Flat metadata key=value (repeatable, max 64). Example: -m branch=main")

    # Substance fields
    p.add_argument("--event", default=None, help="Substance: hook event name (e.g., PostToolUse)")
    p.add_argument("--tool-name", default=None, help="Substance: tool name")
    p.add_argument("--command", default=None, help="Substance: redacted command/tool input")
    p.add_argument("--prompt", default=None, help="Substance: redacted user prompt")
    p.add_argument("--file-path", action="append", default=[], dest="file_paths", help="Local file to upload and attach. Local paths are not sent unless --include-local-paths is set. Repeatable; the API renders one primary attachment plus legacy image/link fallbacks.")
    p.add_argument("--reference-file-path", action="append", default=[], dest="reference_file_paths", help="Metadata-only referenced file path; does not upload bytes (repeatable)")
    p.add_argument("--include-local-paths", action="store_true", help="Also send --file-path values as file_paths metadata. Off by default for privacy.")
    p.add_argument("--parent-id", default=None, help="Substance: parent message ID")
    p.add_argument("--subagent-id", default=None, help="Substance: subagent ID")
    p.add_argument("--status", default=None, help="Substance: status string (e.g., success/failed)")

    # Connection / behavior
    p.add_argument("--api-key", default=None, help="Override API key (prefer env/config; command-line args can appear in shell history)")
    p.add_argument("--base-url", default=None, help=f"Override host base (default: {DEFAULT_BASE_URL})")
    p.add_argument("--api-path", default=None, help=f"Override API path (default: {DEFAULT_API_PATH})")
    p.add_argument("--config", default=None, help="Config JSON path (default: ~/.config/agent-notifier/config.json)")
    p.add_argument("--save-config", action="store_true", help="Save api_key/project/base_url/api_path to config and exit")
    p.add_argument("--timeout", type=float, default=15.0, help="HTTP timeout per attempt in seconds (default: 15)")
    p.add_argument("--max-retries", type=int, default=2, help="Retries for transient network/5xx/429 failures (default: 2)")
    p.add_argument("--dry-run", action="store_true", help="Print payload + endpoint, do not upload or send")
    p.add_argument("--json", action="store_true", help="Print machine-readable JSON for success or dry-run output")
    p.add_argument("--id-only", action="store_true", help="Print only the notification ID on success")
    p.add_argument("--quiet", "-q", action="store_true", help="Suppress success output (errors still printed)")
    return p


def validate_https_url(value: str, field: str) -> str:
    url = value.strip()
    parsed = urllib.parse.urlparse(url)
    if parsed.scheme.lower() != "https" or not parsed.netloc:
        raise NotifyError(f"{field} must be an HTTPS URL")
    if parsed.username or parsed.password:
        raise NotifyError(f"{field} must not contain credentials")
    return url


def validate_click_url(value: str, field: str) -> str:
    url = value.strip()
    parsed = urllib.parse.urlparse(url)
    if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc:
        raise NotifyError(f"{field} must be an HTTP(S) URL")
    if parsed.username or parsed.password:
        raise NotifyError(f"{field} must not contain credentials")
    return url


def validate_uuid(value: str, field: str) -> str:
    try:
        return str(uuid.UUID(value.strip()))
    except (ValueError, AttributeError) as exc:
        raise NotifyError(f"{field} must be a UUID") from exc


def resolve_client_event_id(args: argparse.Namespace) -> str | None:
    if args.no_client_event_id:
        return None
    if args.client_event_id:
        return validate_uuid(args.client_event_id, "client_event_id")
    return str(uuid.uuid4())


def build_payload(args: argparse.Namespace, project: str, client_event_id: str | None) -> dict[str, Any]:
    title = args.title or project
    if len(title) > MAX_TITLE:
        raise NotifyError(f"title exceeds {MAX_TITLE} chars")
    if len(args.message) > MAX_MESSAGE:
        raise NotifyError(f"message exceeds {MAX_MESSAGE} chars")
    if args.progress is not None and not 0.0 <= args.progress <= 1.0:
        raise NotifyError("progress must be between 0.0 and 1.0")
    if args.ttl is not None and args.ttl < 0:
        raise NotifyError("ttl must be non-negative")
    tts_option_used = any(
        [
            args.voice is not None,
            args.voice_engine is not None,
            args.speed is not None,
            args.rewrite_for_tts,
        ]
    )
    if tts_option_used and not args.as_audio:
        raise NotifyError("TTS options require --as-audio")
    tts_voice = DEFAULT_TTS_VOICE
    tts_engine = DEFAULT_TTS_ENGINE
    tts_speed = DEFAULT_TTS_SPEED
    if args.as_audio:
        tts_voice = (args.voice or DEFAULT_TTS_VOICE).strip().lower()
        if not TTS_VOICE_RE.match(tts_voice):
            raise NotifyError("--voice must be a safe Kokoro voice id like af_bella")
        tts_engine = (args.voice_engine or DEFAULT_TTS_ENGINE).strip().lower()
        if tts_engine != DEFAULT_TTS_ENGINE:
            raise NotifyError("--voice-engine currently supports only kokoro")
        tts_speed = args.speed if args.speed is not None else DEFAULT_TTS_SPEED
        if not MIN_TTS_SPEED <= tts_speed <= MAX_TTS_SPEED:
            raise NotifyError(f"--speed must be between {MIN_TTS_SPEED} and {MAX_TTS_SPEED}")

    buttons = args.buttons or []
    if len(buttons) > MAX_BUTTONS:
        raise NotifyError(f"maximum {MAX_BUTTONS} buttons allowed")
    cleaned_buttons = []
    for label in buttons:
        text = str(label).strip()
        if not text:
            raise NotifyError("button labels cannot be empty")
        if len(text) > MAX_BUTTON_LABEL:
            raise NotifyError(f"button label exceeds {MAX_BUTTON_LABEL} chars: {text[:24]!r}...")
        cleaned_buttons.append(text)

    referenced_paths = [*getattr(args, "reference_file_paths", [])]
    if args.include_local_paths:
        referenced_paths.extend(args.file_paths)
    if len(referenced_paths) > MAX_FILE_PATHS:
        raise NotifyError(f"file_paths metadata supports at most {MAX_FILE_PATHS} entries")

    notification_type = args.type
    if notification_type is None:
        if args.progress is not None:
            notification_type = "progress"
        elif args.file_paths:
            notification_type = "file"
        else:
            notification_type = "alert"

    payload: dict[str, Any] = {
        "project": project,
        "type": notification_type,
        "title": title,
        "message": args.message,
        "priority": args.priority,
    }
    if client_event_id:
        payload["client_event_id"] = client_event_id
    if args.session_id:
        payload["session_id"] = validate_uuid(args.session_id, "session_id")
    if args.progress is not None:
        payload["progress"] = args.progress
    if args.image_url:
        payload["image_url"] = validate_https_url(args.image_url, "image_url")
    if args.as_audio:
        payload["as_audio"] = True
        payload["voice"] = tts_voice
        payload["voice_engine"] = tts_engine
        payload["speed"] = tts_speed
        payload["rewrite_for_tts"] = bool(args.rewrite_for_tts)
        tts_text = tts_text_from_file_paths(args.file_paths)
        if tts_text:
            payload["tts_text"] = tts_text
    if args.url:
        payload["url"] = validate_click_url(args.url, "url")
    if args.url_title:
        if not args.url and not args.file_paths:
            raise NotifyError("--url-title requires --url unless --file-path will populate a file link")
        payload["url_title"] = args.url_title.strip()
    if cleaned_buttons:
        payload["buttons"] = cleaned_buttons
    if args.sound:
        payload["sound"] = args.sound.strip()
    if args.ttl is not None:
        payload["ttl"] = args.ttl
    if args.device:
        payload["device"] = args.device.strip()
    if args.color:
        payload["color"] = args.color.strip().lower()
    if args.metadata:
        payload["metadata"] = parse_metadata(args.metadata)

    # Substance fields
    if args.event:
        payload["event"] = args.event.strip()
    if args.tool_name:
        payload["tool_name"] = args.tool_name.strip()
    if args.command:
        payload["command"] = args.command.strip()
    if args.prompt:
        payload["prompt"] = args.prompt.strip()
    if referenced_paths:
        payload["file_paths"] = referenced_paths
    if args.parent_id:
        payload["parent_id"] = validate_uuid(args.parent_id, "parent_id")
    if args.subagent_id:
        payload["subagent_id"] = args.subagent_id.strip()
    if args.status:
        payload["status"] = args.status.strip()
    return payload


def guess_upload_content_type(path: Path) -> str:
    """Return a stable content type for the local upload."""
    mimetypes.add_type("text/markdown", ".md")
    mimetypes.add_type("image/webp", ".webp")
    mimetypes.add_type("image/heic", ".heic")
    mimetypes.add_type("audio/mp4", ".m4a")
    guessed, _ = mimetypes.guess_type(path.name)
    return guessed or "application/octet-stream"


def upload_kind(upload: dict[str, Any]) -> str:
    kind = str(upload.get("kind") or "").strip().lower()
    if kind in {"image", "audio", "video", "file"}:
        return kind
    media_type = str(upload.get("mime_type") or upload.get("content_type") or "").split(";", 1)[0].strip().lower()
    if media_type.startswith("image/"):
        return "image"
    if media_type.startswith("audio/"):
        return "audio"
    if media_type.startswith("video/"):
        return "video"
    return "file"


def is_tts_text_path(path: Path) -> bool:
    suffix = path.suffix.lower()
    if suffix in {".md", ".markdown", ".txt"}:
        return True
    content_type = guess_upload_content_type(path).split(";", 1)[0].strip().lower()
    return content_type.startswith("text/") or content_type in {"application/markdown", "text/markdown"}


def tts_text_from_file_paths(raw_paths: list[str]) -> str | None:
    for raw_path in raw_paths:
        path = resolve_upload_path(raw_path)
        if not path.is_file() or not is_tts_text_path(path):
            continue
        if path.stat().st_size > MAX_MEDIA_UPLOAD_BYTES:
            raise NotifyError(f"file exceeds {MAX_MEDIA_UPLOAD_BYTES // (1024 * 1024)} MiB upload limit: {raw_path}")
        text = path.read_text(encoding="utf-8", errors="replace").strip()
        if not text:
            continue
        return text[:MAX_TTS_TEXT_CHARS]
    return None


def media_endpoint_from_messages(endpoint: str) -> str:
    if endpoint.endswith("/messages"):
        return endpoint[: -len("/messages")] + "/media"
    return endpoint.rstrip("/") + "/media"


def resolve_upload_path(raw_path: str) -> Path:
    path = Path(raw_path).expanduser()
    if not path.is_absolute():
        path = Path.cwd() / path
    return path.resolve()


def probe_duration_ms(path: Path, content_type: str) -> int | None:
    media_type = (content_type or "").split(";", 1)[0].strip().lower()
    if not (media_type.startswith("audio/") or media_type.startswith("video/")):
        return None
    try:
        from mutagen import File as MutagenFile  # type: ignore
    except Exception:
        return None
    try:
        media = MutagenFile(path)
        length = getattr(getattr(media, "info", None), "length", None)
        if length is None:
            return None
        value = int(float(length) * 1000)
        return value if value >= 0 else None
    except Exception:
        return None


def multipart_body(path: Path, boundary: str, content_type: str, duration_ms: int | None = None) -> bytes:
    filename = path.name.replace("\\", "_").replace('"', "_") or "attachment"
    body = bytearray()
    body.extend(f"--{boundary}\r\n".encode("utf-8"))
    body.extend(f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode("utf-8"))
    body.extend(f"Content-Type: {content_type}\r\n\r\n".encode("utf-8"))
    body.extend(path.read_bytes())
    if duration_ms is not None:
        body.extend(f"\r\n--{boundary}\r\n".encode("utf-8"))
        body.extend(b'Content-Disposition: form-data; name="duration_ms"\r\n\r\n')
        body.extend(str(duration_ms).encode("utf-8"))
    body.extend(f"\r\n--{boundary}--\r\n".encode("utf-8"))
    return bytes(body)


def response_body(raw: bytes) -> dict[str, Any] | str:
    text = raw.decode("utf-8", errors="replace")
    if not text:
        return ""
    try:
        decoded = json.loads(text)
    except json.JSONDecodeError:
        return text
    return decoded if isinstance(decoded, dict) else text


def headers_dict(headers: Any) -> dict[str, str]:
    return {str(k).lower(): str(v) for k, v in headers.items()}


def retry_after_seconds(headers: dict[str, str]) -> float | None:
    raw = headers.get("retry-after", "").strip()
    if not raw:
        return None
    if raw.isdigit():
        return float(raw)
    try:
        dt = parsedate_to_datetime(raw)
        return max(0.0, dt.timestamp() - time.time())
    except (TypeError, ValueError, OverflowError):
        return None


def retry_delay(attempt: int, headers: dict[str, str] | None = None) -> float:
    if headers:
        advertised = retry_after_seconds(headers)
        if advertised is not None:
            return min(advertised, 30.0)
    base = min(0.5 * (2**attempt), 8.0)
    return base + random.uniform(0.0, 0.25)


def open_json_request(req: urllib.request.Request, timeout: float) -> HTTPResult:
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return HTTPResult(resp.status, response_body(resp.read()), headers_dict(resp.headers))
    except urllib.error.HTTPError as exc:
        return HTTPResult(exc.code, response_body(exc.read()), headers_dict(exc.headers))
    except urllib.error.URLError as exc:
        raise NotifyError(f"network error: {exc.reason}") from exc


def upload_file_once(endpoint: str, api_key: str, raw_path: str, timeout: float) -> HTTPResult:
    path = resolve_upload_path(raw_path)
    if not path.is_file():
        raise NotifyError(f"file upload path does not exist or is not a file: {raw_path}")
    size = path.stat().st_size
    if size > MAX_MEDIA_UPLOAD_BYTES:
        raise NotifyError(f"file exceeds {MAX_MEDIA_UPLOAD_BYTES // (1024 * 1024)} MiB upload limit: {raw_path}")

    content_type = guess_upload_content_type(path)
    duration_ms = probe_duration_ms(path, content_type)
    boundary = f"Boundary-{uuid.uuid4().hex}"
    req = urllib.request.Request(
        endpoint,
        data=multipart_body(path, boundary, content_type, duration_ms),
        headers={
            "Content-Type": f"multipart/form-data; boundary={boundary}",
            "X-API-Key": api_key,
            "User-Agent": USER_AGENT,
        },
        method="POST",
    )
    result = open_json_request(req, timeout=max(timeout, 30.0))
    if isinstance(result.body, dict):
        result.body["filename"] = path.name
        result.body["content_type"] = result.body.get("content_type") or content_type
        result.body["mime_type"] = result.body.get("mime_type") or result.body["content_type"]
        result.body["kind"] = upload_kind(result.body)
        result.body["size_bytes"] = result.body.get("size_bytes") or size
        if duration_ms is not None and result.body.get("duration_ms") is None:
            result.body["duration_ms"] = duration_ms
    return result


def format_http_error(context: str, status: int, body: dict[str, Any] | str) -> str:
    detail = body.get("error") if isinstance(body, dict) else str(body)
    detail = str(detail or "").strip()
    if status == 400:
        return f"{context} failed validation: {detail or 'bad request'}"
    if status == 401:
        return f"{context} authentication failed: check AGENT_NOTIFIER_API_KEY or run --save-config"
    if status == 413:
        return f"{context} file is too large for the current media limit"
    if status == 429:
        return f"{context} was rate limited; retry after the advertised delay"
    if status in {500, 502, 503, 504}:
        return f"{context} service is temporarily unavailable (HTTP {status})"
    return f"{context} failed HTTP {status}: {detail[:240] or 'unexpected response'}"


def upload_file(endpoint: str, api_key: str, raw_path: str, timeout: float, max_retries: int) -> dict[str, Any]:
    last: HTTPResult | None = None
    for attempt in range(max_retries + 1):
        result = upload_file_once(endpoint, api_key, raw_path, timeout)
        if 200 <= result.status < 300:
            if not isinstance(result.body, dict):
                raise NotifyError("media upload returned a non-JSON response")
            return result.body
        last = result
        if result.status not in TRANSIENT_STATUSES or attempt >= max_retries:
            break
        time.sleep(retry_delay(attempt, result.headers))
    assert last is not None
    raise NotifyError(format_http_error("media upload", last.status, last.body))


def attachment_from_upload(upload: dict[str, Any]) -> dict[str, Any]:
    attachment = {
        "id": upload.get("id"),
        "kind": upload_kind(upload),
        "url": upload.get("blob_url") or upload.get("image_url"),
        "mime_type": upload.get("mime_type") or upload.get("content_type") or "application/octet-stream",
        "size_bytes": int(upload.get("size_bytes") or 0),
    }
    if upload.get("duration_ms") is not None:
        attachment["duration_ms"] = int(upload["duration_ms"])
    if upload.get("filename"):
        attachment["file_name"] = upload["filename"]
    return {key: value for key, value in attachment.items() if value not in (None, "")}


def attach_uploaded_files(payload: dict[str, Any], uploads: list[dict[str, Any]]) -> None:
    """Map uploaded blobs onto the /messages multi-attachment contract.

    The full upload set is sent as `attachments`. For compatibility, the first
    upload also fills legacy `attachment`, the first image fills `image_url`, and
    the first non-image file fills `url`/`url_title` when the caller did not
    supply an explicit link. Local absolute paths are never added here.
    """
    if not uploads:
        return

    first_upload = uploads[0]
    first_image = next((item for item in uploads if upload_kind(item) == "image"), None)
    first_file = next((item for item in uploads if upload_kind(item) != "image"), None)

    attachments = [attachment for attachment in (attachment_from_upload(item) for item in uploads) if attachment.get("url")]
    if attachments:
        payload["attachments"] = attachments

    if not payload.get("attachment"):
        attachment = attachments[0] if attachments else attachment_from_upload(first_upload)
        if attachment.get("url"):
            payload["attachment"] = attachment

    if first_image and not payload.get("image_url"):
        payload["image_url"] = first_image.get("image_url") or first_image.get("blob_url")
    if first_file and not payload.get("url"):
        payload["url"] = first_file.get("blob_url") or first_file.get("image_url")
        payload.setdefault("url_title", first_file.get("filename") or "Open uploaded file")

    metadata = dict(payload.get("metadata") or {})
    metadata.setdefault("media_upload_count", str(len(uploads)))
    metadata.setdefault("media_upload_filenames", ", ".join(str(item.get("filename") or "attachment") for item in uploads)[:512])
    if len(uploads) > 1:
        metadata.setdefault("media_upload_extra_count", str(len(uploads) - 1))
    if first_image and first_file:
        metadata.setdefault("media_upload_kind", "mixed")
    elif first_image:
        metadata.setdefault("media_upload_kind", "image")
    else:
        metadata.setdefault("media_upload_kind", "file")
    payload["metadata"] = metadata


def send_once(endpoint: str, api_key: str, payload: dict[str, Any], timeout: float) -> HTTPResult:
    body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
    req = urllib.request.Request(
        endpoint,
        data=body,
        headers={"Content-Type": "application/json", "X-API-Key": api_key, "User-Agent": USER_AGENT},
        method="POST",
    )
    return open_json_request(req, timeout=timeout)


def send(endpoint: str, api_key: str, payload: dict[str, Any], timeout: float, max_retries: int) -> HTTPResult:
    last: HTTPResult | None = None
    for attempt in range(max_retries + 1):
        result = send_once(endpoint, api_key, payload, timeout)
        if 200 <= result.status < 300:
            return result
        last = result
        if result.status not in TRANSIENT_STATUSES or attempt >= max_retries:
            break
        time.sleep(retry_delay(attempt, result.headers))
    assert last is not None
    return last


def fetch_message_status(endpoint: str, api_key: str, timeout: float) -> HTTPResult:
    req = urllib.request.Request(
        endpoint,
        headers={"X-API-Key": api_key, "User-Agent": USER_AGENT},
        method="GET",
    )
    return open_json_request(req, timeout=timeout)


def build_endpoints(base_url: str, api_path: str) -> tuple[str, str]:
    parsed = urllib.parse.urlparse(base_url)
    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
        raise NotifyError("base URL must be an HTTP(S) URL")
    base = base_url.rstrip("/")
    path = "/" + api_path.strip().strip("/")
    messages_endpoint = f"{base}{path}/messages"
    return messages_endpoint, media_endpoint_from_messages(messages_endpoint)


def message_status_endpoint(messages_endpoint: str, message_id: str) -> str:
    return f"{messages_endpoint}/{urllib.parse.quote(message_id.strip(), safe='')}/status"


def print_dry_run(endpoint: str, media_endpoint: str, payload: dict[str, Any], args: argparse.Namespace) -> None:
    upload_plan = [
        {
            "path": str(Path(raw).expanduser()),
            "filename": Path(raw).expanduser().name or "attachment",
            "local_path_sent": bool(args.include_local_paths),
        }
        for raw in args.file_paths
    ]
    if args.json:
        print(json.dumps({"messages_endpoint": endpoint, "media_endpoint": media_endpoint, "payload": payload, "uploads": upload_plan}, indent=2, ensure_ascii=False))
        return
    print(f"POST {endpoint}")
    if args.file_paths:
        print(f"UPLOAD {media_endpoint}")
        print(json.dumps({"files": upload_plan}, indent=2, ensure_ascii=False))
        if not args.include_local_paths:
            print("NOTE local upload paths are not included in payload.file_paths unless --include-local-paths is set", file=sys.stderr)
    print(json.dumps(payload, indent=2, ensure_ascii=False))


def warn_multiple_uploads(args: argparse.Namespace) -> None:
    if len(args.file_paths) <= 1 or args.quiet or args.json or args.id_only:
        return
    print(
        "warning: sending multiple uploads as attachments; older clients may only render the primary attachment.",
        file=sys.stderr,
    )


def print_success(args: argparse.Namespace, body: dict[str, Any] | str, status: int) -> None:
    if args.quiet:
        return
    if args.id_only:
        if isinstance(body, dict) and body.get("id"):
            print(body["id"])
        return
    if args.json:
        print(json.dumps(body, indent=2, ensure_ascii=False) if isinstance(body, dict) else json.dumps({"status": status, "body": body}, indent=2))
        return
    if isinstance(body, dict):
        msg_id = body.get("id", "")
        project = body.get("project_name", "")
        suffix = f" project={project}" if project else ""
        print(f"sent ok status={status} id={msg_id}{suffix}")
    else:
        print(f"sent ok status={status}")


def print_status_result(args: argparse.Namespace, body: dict[str, Any] | str, status: int) -> None:
    if args.quiet:
        return
    if args.json:
        print(json.dumps(body, indent=2, ensure_ascii=False) if isinstance(body, dict) else json.dumps({"status": status, "body": body}, indent=2))
        return
    if isinstance(body, dict):
        msg_id = body.get("id", "")
        audio_status = body.get("audio_status") or "none"
        attachment_count = body.get("attachments_count", 0)
        print(f"status ok http={status} id={msg_id} audio_status={audio_status} attachments={attachment_count}")
    else:
        print(f"status ok http={status}")


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)

    try:
        config_path = Path(args.config).expanduser() if args.config else default_config_path()
        config = load_config(config_path)
        local_env = load_local_env()

        api_key = pick_setting(args.api_key, os.environ.get("AGENT_NOTIFIER_API_KEY"), local_env.get("AGENT_NOTIFIER_API_KEY"), config.get("api_key"))
        base_url = pick_setting(args.base_url, os.environ.get("AGENT_NOTIFIER_URL"), local_env.get("AGENT_NOTIFIER_URL"), config.get("base_url"), default=DEFAULT_BASE_URL)
        api_path = pick_setting(args.api_path, os.environ.get("AGENT_NOTIFIER_API_PATH"), local_env.get("AGENT_NOTIFIER_API_PATH"), config.get("api_path"), default=DEFAULT_API_PATH)
        project = pick_setting(args.project, os.environ.get("AGENT_NOTIFIER_PROJECT"), local_env.get("AGENT_NOTIFIER_PROJECT"), config.get("project")) or detect_project()

        if args.max_retries < 0:
            raise NotifyError("--max-retries must be non-negative")
        if args.timeout <= 0:
            raise NotifyError("--timeout must be positive")

        if args.save_config:
            save_config(
                config_path,
                {
                    "api_key": api_key or "",
                    "project": project,
                    "base_url": base_url or DEFAULT_BASE_URL,
                    "api_path": api_path or DEFAULT_API_PATH,
                },
            )
            if not args.quiet:
                print(f"saved config to {config_path}")
            return 0

        endpoint, media_endpoint = build_endpoints(base_url or DEFAULT_BASE_URL, api_path or DEFAULT_API_PATH)

        if args.check_status:
            status_endpoint = message_status_endpoint(endpoint, args.check_status)
            if args.dry_run:
                if args.json:
                    print(json.dumps({"status_endpoint": status_endpoint}, indent=2, ensure_ascii=False))
                else:
                    print(f"GET {status_endpoint}")
                return 0
            if not api_key:
                raise NotifyError("AGENT_NOTIFIER_API_KEY is required. Set the env var, add it to .env.local, or run --save-config --api-key <key>.")
            result = fetch_message_status(status_endpoint, api_key, args.timeout)
            if 200 <= result.status < 300:
                print_status_result(args, result.body, result.status)
                return 0
            raise NotifyError(format_http_error("message status", result.status, result.body))

        if not args.message.strip() and not (args.as_audio and args.file_paths):
            raise NotifyError("message is required (positional argument)")

        client_event_id = resolve_client_event_id(args)
        payload = build_payload(args, project, client_event_id)

        if args.dry_run:
            print_dry_run(endpoint, media_endpoint, payload, args)
            return 0

        if not api_key:
            raise NotifyError("AGENT_NOTIFIER_API_KEY is required. Set the env var, add it to .env.local, or run --save-config --api-key <key>.")

        warn_multiple_uploads(args)
        uploads = [upload_file(media_endpoint, api_key, raw_path, args.timeout, args.max_retries) for raw_path in args.file_paths]
        attach_uploaded_files(payload, uploads)

        send_retries = args.max_retries if payload.get("client_event_id") else 0
        result = send(endpoint, api_key, payload, args.timeout, send_retries)
        if 200 <= result.status < 300:
            print_success(args, result.body, result.status)
            return 0
        raise NotifyError(format_http_error("message send", result.status, result.body))
    except NotifyError as exc:
        print(f"notify.py: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
