#!/usr/bin/env python3
"""slack-cli - bounded, agent-first command line for the Slack Web API.

Reads Slack data and, with explicit confirmation, sends messages over HTTPS
using only the Python standard library. Covers messages, channels, threads,
search, files, and webhook signature verification.

Design rules:

- Read-only by default. Every state-changing command (sending a message or
  replying in a thread) is a guarded mutation: it requires --dry-run to
  preview, then --yes to confirm. Mutation requires explicit confirmation.
- Bounded reads: every listing command caps results with --limit and never
  pages past the requested cap.
- --json emits machine-readable JSON; the default is human-readable text.
- --help works with no SLACK_TOKEN set and makes no network calls.

Environment:
  SLACK_TOKEN          Slack bot/user token (xoxb-... / xoxp-...)
  SLACK_WEBHOOK_SECRET Slack app signing secret (for `webhook verify`)

Exit codes: 0 success, 1 Slack API error or failed check, 2 usage error.
"""
import argparse
import hashlib
import hmac
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Dict, List, Optional

API_BASE = os.environ.get("SLACK_API_BASE", "https://slack.com/api")
DEFAULT_LIMIT = 20
MAX_LIMIT = 100
REQUEST_TIMEOUT = 15
WEBHOOK_MAX_AGE_SECONDS = 300
TEXT_TRUNCATE = 500


class SlackError(Exception):
    """Raised when the Slack API returns ok:false or transport fails."""


def get_token() -> str:
    token = os.environ.get("SLACK_TOKEN", "")
    if not token:
        raise SlackError("SLACK_TOKEN environment variable is not set")
    return token


def api_call(method: str, fields: Dict[str, str], token: str) -> Dict[str, Any]:
    """POST form fields to a Slack API method and return the JSON payload."""
    data = urllib.parse.urlencode(fields).encode("utf-8")
    request = urllib.request.Request(
        f"{API_BASE}/{method}",
        data=data,
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/x-www-form-urlencoded",
        },
        method="POST",
    )
    try:
        with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
            payload = json.loads(response.read().decode("utf-8"))
    except urllib.error.HTTPError as error:
        raise SlackError(
            f"Slack API HTTP {error.code}: {error.read().decode('utf-8', 'replace')}"
        ) from error
    except urllib.error.URLError as error:
        raise SlackError(f"Slack API unreachable: {error.reason}") from error
    except json.JSONDecodeError as error:
        raise SlackError(f"Slack API returned non-JSON: {error}") from error
    if not payload.get("ok"):
        raise SlackError(f"Slack API error: {payload.get('error', 'unknown')}")
    return payload


def truncate(text: str, limit: int = TEXT_TRUNCATE) -> str:
    if len(text) <= limit:
        return text
    return text[: limit - 1] + "…"


def summarize_message(item: Dict[str, Any]) -> Dict[str, Any]:
    return {
        "ts": item.get("ts", ""),
        "user": item.get("user", ""),
        "type": item.get("type", "message"),
        "channel": item.get("channel", ""),
        "text": truncate(item.get("text", "")),
        "thread_ts": item.get("thread_ts", ""),
        "reply_count": item.get("reply_count"),
    }


def summarize_channel(item: Dict[str, Any]) -> Dict[str, Any]:
    return {
        "id": item.get("id", ""),
        "name": item.get("name", ""),
        "is_channel": item.get("is_channel", False),
        "is_private": item.get("is_private", False),
        "is_archived": item.get("is_archived", False),
        "num_members": item.get("num_members"),
    }


def summarize_file(item: Dict[str, Any]) -> Dict[str, Any]:
    return {
        "id": item.get("id", ""),
        "name": item.get("name", ""),
        "title": truncate(item.get("title", "")),
        "filetype": item.get("filetype", ""),
        "size": item.get("size"),
        "created": item.get("created"),
        "permalink": item.get("permalink", ""),
    }


# --------------------------------------------------------------------------
# Command implementations
# --------------------------------------------------------------------------


def cmd_channels_list(args: argparse.Namespace, token: str) -> Dict[str, Any]:
    payload = api_call(
        "conversations.list",
        {
            "types": args.types,
            "limit": str(args.limit),
            "exclude_archived": "true" if args.exclude_archived else "false",
        },
        token,
    )
    channels = [summarize_channel(c) for c in payload.get("channels", [])]
    return {"ok": True, "channels": channels, "response_metadata": payload.get("response_metadata", {})}


def cmd_messages_list(args: argparse.Namespace, token: str) -> Dict[str, Any]:
    fields = {"channel": args.channel, "limit": str(args.limit)}
    if args.cursor:
        fields["cursor"] = args.cursor
    payload = api_call("conversations.history", fields, token)
    messages = [summarize_message(m) for m in payload.get("messages", [])]
    return {"ok": True, "channel": args.channel, "messages": messages,
            "response_metadata": payload.get("response_metadata", {})}


def cmd_messages_send(args: argparse.Namespace, token: str) -> Dict[str, Any]:
    if not args.dry_run and not args.yes:
        raise SlackError(
            "refusing to send without confirmation: pass --dry-run to preview "
            "or --yes to confirm the mutation"
        )
    if args.dry_run:
        return {
            "ok": True,
            "dry_run": True,
            "would_post": {
                "channel": args.channel,
                "thread_ts": args.thread_ts or None,
                "text": truncate(args.text),
            },
        }
    fields = {"channel": args.channel, "text": args.text}
    if args.thread_ts:
        fields["thread_ts"] = args.thread_ts
    payload = api_call("chat.postMessage", fields, token)
    return {
        "ok": True,
        "ts": payload.get("ts", ""),
        "channel": payload.get("channel", ""),
        "message": summarize_message(payload.get("message", {})),
    }


def cmd_threads_list(args: argparse.Namespace, token: str) -> Dict[str, Any]:
    fields = {"channel": args.channel, "ts": args.ts, "limit": str(args.limit)}
    if args.cursor:
        fields["cursor"] = args.cursor
    payload = api_call("conversations.replies", fields, token)
    messages = [summarize_message(m) for m in payload.get("messages", [])]
    return {"ok": True, "channel": args.channel, "thread_ts": args.ts, "messages": messages,
            "response_metadata": payload.get("response_metadata", {})}


def cmd_search(args: argparse.Namespace, token: str) -> Dict[str, Any]:
    payload = api_call(
        "search.messages",
        {"query": args.query, "count": str(args.limit), "sort": "timestamp", "sort_dir": "desc"},
        token,
    )
    matches = payload.get("messages", {}).get("matches", [])
    results = [summarize_message(m) for m in matches[: args.limit]]
    return {
        "ok": True,
        "query": args.query,
        "total_matches": payload.get("messages", {}).get("total", 0),
        "matches": results,
    }


def cmd_files_list(args: argparse.Namespace, token: str) -> Dict[str, Any]:
    fields = {"limit": str(args.limit), "show_files_hidden_by_limit": "true"}
    if args.channel:
        fields["channel"] = args.channel
    if args.user:
        fields["user"] = args.user
    payload = api_call("files.list", fields, token)
    files = [summarize_file(f) for f in payload.get("files", [])]
    return {"ok": True, "files": files, "response_metadata": payload.get("response_metadata", {})}


def verify_webhook_signature(body: bytes, signature: str, timestamp: str, secret: str) -> Dict[str, Any]:
    """Verify a Slack request against the app signing secret (HMAC-SHA256).

    Slack signs the request as "v0=hex(HMAC_SHA256(secret, 'v0:' + timestamp
    + ':' + body))" and also sends X-Slack-Request-Timestamp. The timestamp
    is checked for replay freshness before the signature is compared.
    """
    if not signature.startswith("v0="):
        raise SlackError(f"unsupported signature format: {signature[:16]!r}")
    try:
        if abs(int(time.time()) - int(timestamp)) > WEBHOOK_MAX_AGE_SECONDS:
            raise SlackError(
                f"webhook timestamp is outside the {WEBHOOK_MAX_AGE_SECONDS}s replay window"
            )
    except ValueError as error:
        raise SlackError(f"webhook timestamp is not a valid Unix timestamp: {timestamp!r}") from error
    base = f"v0:{timestamp}:".encode("utf-8") + body
    expected = "v0=" + hmac.new(secret.encode("utf-8"), base, hashlib.sha256).hexdigest()
    valid = hmac.compare_digest(expected, signature)
    if not valid:
        raise SlackError("webhook signature does not match the signing secret")
    return {"ok": True, "verified": True, "timestamp": timestamp}


def cmd_webhook_verify(args: argparse.Namespace, unused_token: str) -> Dict[str, Any]:
    try:
        body = open(args.body_file, "rb").read()
    except OSError as error:
        raise SlackError(f"cannot read body file {args.body_file}: {error}") from error
    secret = args.secret or os.environ.get("SLACK_WEBHOOK_SECRET", "")
    if not secret:
        raise SlackError(
            "no webhook secret available: pass --secret or set SLACK_WEBHOOK_SECRET"
        )
    return verify_webhook_signature(body, args.signature, args.timestamp, secret)


# --------------------------------------------------------------------------
# Output helpers
# --------------------------------------------------------------------------


def emit(data: Dict[str, Any], json_mode: bool) -> int:
    if json_mode:
        print(json.dumps(data, indent=2, sort_keys=True))
    else:
        _emit_human(data)
    return 0


def _emit_human(data: Dict[str, Any]) -> None:
    if "channels" in data:
        for channel in data["channels"]:
            flag = "#" if channel.get("is_channel") else "🔒" if channel.get("is_private") else "?"
            print(f"{flag} {channel['name']}  <{channel['id']}>  members={channel.get('num_members') or '?'}")
    elif "messages" in data and "thread_ts" not in data:
        for message in data["messages"]:
            print(f"[{message['ts']}] <{message['user']}> {message['text']}")
    elif "messages" in data:
        for message in data["messages"]:
            print(f"[{message['ts']}] <{message['user']}> (reply) {message['text']}")
    elif "matches" in data:
        print(f"total matches: {data['total_matches']}")
        for match in data["matches"]:
            print(f"[{match['ts']}] <{match['user']}> {match['text']}")
    elif "files" in data:
        for item in data["files"]:
            print(f"{item['name']}  <{item['id']}>  {item['filetype']}  {item['size']} bytes")
    elif data.get("dry_run"):
        print("DRY RUN (no message sent):")
        print(f"  channel: {data['would_post']['channel']}")
        print(f"  text:    {data['would_post']['text']}")
    elif "ts" in data and "message" in data:
        print(f"posted to <{data['channel']}> as message {data['ts']}")
    elif data.get("verified"):
        print("webhook signature verified (HMAC-SHA256 match)")
    else:
        print(json.dumps(data, indent=2, sort_keys=True))


# --------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="slack-cli",
        description=(
            "Bounded, agent-first CLI for the Slack Web API: messages, channels, "
            "threads, search, files, and webhook signature verification. "
            "Read-only by default; sending requires --dry-run then --yes."
        ),
    )
    parser.add_argument("--json", action="store_true", help="emit machine-readable JSON output")
    parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT, metavar="N",
                        help=f"cap reads at N results (default {DEFAULT_LIMIT}, max {MAX_LIMIT})")
    sub = parser.add_subparsers(dest="command", required=True)

    channels = sub.add_parser("channels", help="list channels (read-only)")
    channels.add_argument("action", nargs="?", default="list", choices=["list"])
    channels.add_argument("--types", default="public_channel,private_channel",
                          help="comma-separated channel types (default: public,private)")
    channels.add_argument("--exclude-archived", action="store_true",
                          help="exclude archived channels")
    channels.add_argument("--cursor", help="pagination cursor from a previous response")

    messages = sub.add_parser("messages", help="list or send messages")
    msg_sub = messages.add_subparsers(dest="action", required=True)

    msg_list = msg_sub.add_parser("list", help="list messages in a channel (read-only)")
    msg_list.add_argument("--channel", required=True, help="channel ID (C...)")
    msg_list.add_argument("--cursor", help="pagination cursor from a previous response")

    msg_send = msg_sub.add_parser("send", help="send a message (guarded mutation)")
    msg_send.add_argument("--channel", required=True, help="channel ID (C...)")
    msg_send.add_argument("--text", required=True, help="message text")
    msg_send.add_argument("--thread-ts", help="reply in a thread at this ts")
    msg_send.add_argument("--dry-run", action="store_true", help="preview the message without sending")
    msg_send.add_argument("--yes", action="store_true", help="confirm the mutation and send")

    threads = sub.add_parser("threads", help="list replies in a thread (read-only)")
    threads.add_argument("action", nargs="?", default="list", choices=["list"])
    threads.add_argument("--channel", required=True, help="channel ID (C...)")
    threads.add_argument("--ts", required=True, help="parent message timestamp (ts)")
    threads.add_argument("--cursor", help="pagination cursor from a previous response")

    search = sub.add_parser("search", help="search messages (read-only)")
    search.add_argument("action", nargs="?", default="messages", choices=["messages"])
    search.add_argument("--query", required=True, help="search query (see Slack search syntax)")

    files = sub.add_parser("files", help="list files (read-only)")
    files.add_argument("action", nargs="?", default="list", choices=["list"])
    files.add_argument("--channel", help="restrict to files in a channel")
    files.add_argument("--user", help="restrict to files shared by a user")

    webhook = sub.add_parser("webhook", help="verify a Slack webhook request (read-only)")
    wv_sub = webhook.add_subparsers(dest="action", required=True)
    wv_verify = wv_sub.add_parser("verify", help="verify X-Slack-Signature (HMAC-SHA256)")
    wv_verify.add_argument("--body-file", required=True,
                           help="path to the raw webhook request body")
    wv_verify.add_argument("--signature", required=True,
                           help="X-Slack-Signature header value (v0=...)")
    wv_verify.add_argument("--timestamp", required=True,
                           help="X-Slack-Request-Timestamp header value (Unix seconds)")
    wv_verify.add_argument("--secret", help="signing secret (default: $SLACK_WEBHOOK_SECRET)")

    return parser


def main(argv: Optional[List[str]] = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    if args.limit < 1 or args.limit > MAX_LIMIT:
        parser.error(f"--limit must be between 1 and {MAX_LIMIT}")
    try:
        if args.command == "webhook":
            result = cmd_webhook_verify(args, "")
        else:
            token = get_token()
            if args.command == "channels":
                result = cmd_channels_list(args, token)
            elif args.command == "messages":
                result = cmd_messages_list(args, token) if args.action == "list" else cmd_messages_send(args, token)
            elif args.command == "threads":
                result = cmd_threads_list(args, token)
            elif args.command == "search":
                result = cmd_search(args, token)
            elif args.command == "files":
                result = cmd_files_list(args, token)
            else:  # pragma: no cover - argparse prevents this
                parser.error(f"unknown command: {args.command}")
        return emit(result, args.json)
    except SlackError as error:
        if args.json:
            print(json.dumps({"ok": False, "error": str(error)}, indent=2, sort_keys=True))
        else:
            print(f"slack-cli: {error}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())
