#!/usr/bin/env python3
"""
sendmsg — Universal message sender for Signal and SMS/iMessage.

Usage:
    sendmsg --signal --to +18005551212 --text "Hello from Signal!"
    sendmsg --signal --recipients group_id_here --text "All members"
    sendmsg --sms --to +18005551212 --text "Hello via SMS!" --service sms
    sendmsg --sms --to +18005551212 --text "Hey" --service imessage --file ~/photo.png
    sendmsg --signal --to +18005551212 --text "Multi" --text "more text"
    sendmsg --signal --to +18005551212 --attach ~/doc.pdf ~/pic.jpg
    sendmsg --signal --to +18005551212 --voice ~/note.m4a   # Send a voice message
    sendmsg --csv messages.csv             # Bulk send from CSV file
    sendmsg --csv messages.csv --delay 2   # Wait N seconds between sends
    sendmsg --csv messages.csv --dry-run   # Preview without sending
    sendmsg --list-signal                  # Show linked Signal accounts
    sendmsg --link-signal                  # Link a new device to your existing Signal account

CSV format (messages.csv):
    method,recipient,name,message,account,service,file,voice,delay
    signal,+1888222333,Alice,Hello via Signal,+1888222333,,,,
    signal,group.ZzBHd3NZO...,Group alert,Hi team,,,,,
    sms,+1234567890,Bob,SMS test,,imessage,~/pic.jpg,,
    signal,+1888222333,Carol,With delay,,,,,3
"""

import argparse
import base64
import configparser
import csv
import json
import mimetypes
import os
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

VERSION = "5.1.0"

CONFIG_PATHS = [
    os.path.expanduser("~/.sendmsg.conf"),
]

# Maximum attachment size accepted before base64-encoding into the JSON body.
# Large files balloon memory and request size; the REST API also rejects them.
MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024  # 100 MB

# Number of rows in a CSV batch above which we ask for confirmation,
# unless --yes is supplied.
CSV_CONFIRM_THRESHOLD = 50

# Audio MIME types that signal-cli will accept as a voice note.
VOICE_AUDIO_EXTS = {".m4a", ".aac", ".ogg", ".opus", ".mp3", ".wav"}


def load_config():
    """Load settings from sendmsg config file, with env var fallback.

    Config file format (INI-style):
        [settings]
        signal_rest_url = http://localhost:8080
        signal_default_account = +1234567890

    Lookup order: env var > config file > hardcoded default.
    """
    defaults = {
        "signal_rest_url": "http://localhost:8080",
        "signal_default_account": "+1234567890",
    }
    env_vars = {
        "signal_rest_url": "SIGNAL_REST_URL",
        "signal_default_account": "SIGNAL_ACCOUNT",
    }

    config = configparser.ConfigParser()
    config_path = None
    for path in CONFIG_PATHS:
        if os.path.isfile(path):
            config.read(path)
            config_path = path
            break

    resolved = {}
    sources = {}
    for key, default in defaults.items():
        env_val = os.environ.get(env_vars[key])
        if env_val:
            resolved[key] = env_val
            sources[key] = f"env (${env_vars[key]})"
        elif config.has_option("settings", key):
            resolved[key] = config.get("settings", key)
            sources[key] = f"config ({config_path})"
        else:
            resolved[key] = default
            sources[key] = "default"

    resolved["_sources"] = sources
    resolved["_config_path"] = config_path
    return resolved


# Load config at module level for use by all functions
CFG = load_config()
SIGNAL_REST_URL = CFG["signal_rest_url"]
SIGNAL_DEFAULT_ACCOUNT = CFG["signal_default_account"]

# The built-in default account is a placeholder; sending from it will fail at
# the Signal server. Track whether the resolved account is still the default
# so we can warn before attempting a real send.
PLACEHOLDER_ACCOUNT = "+1234567890"

if not (SIGNAL_REST_URL.startswith("http://") or SIGNAL_REST_URL.startswith("https://")):
    print(
        f"⚠️ Warning: signal_rest_url '{SIGNAL_REST_URL}' does not look like a "
        f"valid URL (expected http:// or https://).",
        file=sys.stderr,
    )


# ---------------------------------------------------------------------------
# Helpers — reusable without argparse objects (needed for CSV dispatch)
# ---------------------------------------------------------------------------

def expand_path(path):
    """Expand '~' and environment variables in a file path.

    CSV rows supply literal paths like '~/pic.jpg'; unlike the shell, Python
    does not expand the tilde, so os.path.isfile('~/pic.jpg') is always False.
    This caused CSV Signal attachments to be silently dropped.
    """
    if not path:
        return path
    return os.path.expanduser(os.path.expandvars(path.strip()))


def normalize_account(account):
    """Ensure an account/phone number carries a leading '+'.

    Group IDs (starting with 'group.') are returned unchanged.
    """
    if not account:
        return account
    account = account.strip()
    if account.startswith("group."):
        return account
    if not account.startswith("+"):
        account = "+" + account
    return account


def looks_like_phone_number(value):
    """Return True if value looks like a phone number.

    Accepts numbers with an explicit '+', and also bare all-digit strings
    (which a user may type without the leading '+'). This is the inverse
    test used by is_group_id, and the two must agree on what a bare number
    is so a phone number is never mis-routed as a group.
    """
    if not value:
        return False
    value = value.strip()
    if value.startswith("+"):
        return value[1:].isdigit() and len(value) > 1
    # A bare run of digits (optionally long) is a phone number, not a group.
    return value.isdigit()


def is_group_id(value):
    """Return True if a recipient value looks like a Signal group ID.

    signal-cli group IDs are either the 'group.<base64>' form used by the
    REST API, or a raw base64 internal group key. Sending a group message
    down the 1:1 'recipient' path is what causes the 'Unregistered user
    <uuid>' errors and the wrong-sender appearance in issue #3, so we treat
    anything that is not a phone number as a group ID.
    """
    if not value:
        return False
    value = value.strip()
    if value.startswith("group."):
        return True
    # Anything that is recognisably a phone number is NOT a group. This now
    # correctly handles bare (no '+') phone numbers, which previously fell
    # through and were mis-detected as group IDs.
    if looks_like_phone_number(value):
        return False
    return True


def normalize_recipient(value):
    """Normalize a recipient for the Signal recipients[] array.

    Phone numbers get a leading '+'; group IDs are passed through unchanged.
    """
    if not value:
        return value
    value = value.strip()
    if is_group_id(value):
        return value
    return normalize_account(value)


def validate_attachment(path, *, as_voice=False):
    """Validate an attachment path.

    Returns the expanded path if valid, otherwise None (with a warning).
    """
    if not path:
        return None
    path = expand_path(path)
    if not os.path.isfile(path):
        print(f"⚠️ Warning: attachment not found: {path}", file=sys.stderr)
        return None
    try:
        size = os.path.getsize(path)
    except OSError as e:
        print(f"⚠️ Warning: cannot read attachment {path}: {e}", file=sys.stderr)
        return None
    if size > MAX_ATTACHMENT_BYTES:
        print(
            f"⚠️ Warning: attachment too large "
            f"({size / 1024 / 1024:.1f} MB > "
            f"{MAX_ATTACHMENT_BYTES / 1024 / 1024:.0f} MB limit): {path}",
            file=sys.stderr,
        )
        return None
    if as_voice:
        ext = os.path.splitext(path)[1].lower()
        if ext not in VOICE_AUDIO_EXTS:
            print(
                f"⚠️ Warning: '{ext}' is not a recognized voice/audio format "
                f"(expected one of {', '.join(sorted(VOICE_AUDIO_EXTS))}); "
                f"sending anyway.",
                file=sys.stderr,
            )
    return path


def send_one_signal(account, recipient, message, group_id=None,
                    attach=None, voice=None):
    """Send a single Signal message. Returns True on success.

    `attach` may be a single path or a list of paths.
    `voice`  is a single audio path to be delivered as a voice note.
    """
    account = normalize_account(account)

    if account == PLACEHOLDER_ACCOUNT:
        print(
            f"⚠️ Warning: sending from the placeholder account {PLACEHOLDER_ACCOUNT}. "
            f"Set SIGNAL_ACCOUNT or signal_default_account in ~/.sendmsg.conf, "
            f"or pass --account.",
            file=sys.stderr,
        )

    target = group_id if group_id else normalize_recipient(recipient)
    payload = {"number": account, "recipients": [target]}

    if message:
        payload["message"] = message

    # Normalize attach to a list of valid paths.
    attach_list = []
    if attach:
        candidates = attach if isinstance(attach, (list, tuple)) else [attach]
        for c in candidates:
            valid = validate_attachment(c)
            if valid:
                attach_list.append(valid)

    voice_path = validate_attachment(voice, as_voice=True) if voice else None

    has_attachment = bool(attach_list)
    has_voice = bool(voice_path)

    if not message and not has_attachment and not has_voice:
        print(
            f"❌ Signal send skipped: no message text, attachment, or voice note "
            f"(recipient: {group_id or recipient})",
            file=sys.stderr,
        )
        return False

    # A voice note is sent as its own message (a voice note cannot be mixed
    # with arbitrary file attachments in a single send).
    overall_ok = True

    if has_voice:
        voice_payload = {"number": account, "recipients": [target], "voice": True}
        if message and not has_attachment:
            # Attach the text to the voice message if there are no other files.
            voice_payload["message"] = message
        vres = signal_rest_post("/v2/send", payload=voice_payload, files=[voice_path])
        overall_ok = overall_ok and (vres is not None)
        # If text rode along with the voice note, don't send it again below.
        if has_attachment and message:
            pass  # text will be sent with the attachments below
        else:
            message = None  # consumed

    if has_attachment:
        files_payload = {"number": account, "recipients": [target]}
        if message:
            files_payload["message"] = message
        ares = signal_rest_post("/v2/send", payload=files_payload, files=attach_list)
        overall_ok = overall_ok and (ares is not None)
    elif message and not has_voice:
        # Pure text-only send.
        tres = signal_rest_post("/v2/send", payload=payload)
        overall_ok = overall_ok and (tres is not None)

    return overall_ok


def send_one_sms(to, message, service, file=None):
    """Send a single SMS/iMessage. Returns True on success.

    `service` must be 'sms' or 'imessage' (required — no auto-detect).
    """
    cmd = ["imsg", "send", "--to", to, "--service", service]
    cmd.extend(["--text", message])
    if file:
        file = expand_path(file)
        if os.path.isfile(file):
            cmd.extend(["--file", file])
        else:
            print(f"⚠️ Warning: file not found, skipping attachment: {file}", file=sys.stderr)

    if not shutil.which("imsg"):
        print("❌ SMS send failed: 'imsg' CLI not found on PATH.", file=sys.stderr)
        return False

    # imsg can hang if the Messages app is unresponsive; bound each attempt
    # with a timeout and retry a couple of times on transient failures.
    max_attempts = 3
    backoff = 2.0
    for attempt in range(1, max_attempts + 1):
        try:
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
        except subprocess.TimeoutExpired:
            if attempt < max_attempts:
                wait = backoff * attempt
                print(
                    f"⚠️ SMS send to {to} timed out; retrying in {wait:.0f}s "
                    f"(attempt {attempt}/{max_attempts})...",
                    file=sys.stderr,
                )
                time.sleep(wait)
                continue
            print(f"❌ SMS send failed: 'imsg' timed out after {max_attempts} attempts.", file=sys.stderr)
            return False
        except OSError as e:
            print(f"❌ SMS send failed: could not run 'imsg': {e}", file=sys.stderr)
            return False

        if result.returncode == 0:
            print(f"✅ SMS/iMessage sent to {to} (service: {service.upper()})")
            return True

        if attempt < max_attempts:
            wait = backoff * attempt
            print(
                f"⚠️ SMS send to {to} failed (exit {result.returncode}); retrying in "
                f"{wait:.0f}s (attempt {attempt}/{max_attempts})...",
                file=sys.stderr,
            )
            time.sleep(wait)
            continue
        print(f"❌ SMS send failed (exit {result.returncode}): {result.stderr.strip()}", file=sys.stderr)
        return False

    return False


# ---------------------------------------------------------------------------
# REST API
# ---------------------------------------------------------------------------

def signal_rest_post(endpoint, payload=None, files=None):
    """Post a JSON request to the signal-cli-rest-api.

    The /v2/send endpoint expects a single JSON body. Attachments are NOT
    sent as multipart/form-data — they must be base64-encoded and placed in
    the "base64_attachments" array, each as a data URI of the form
    'data:<mime>;filename=<name>;base64,<data>'. Sending multipart produces
    HTTP 400 "invalid request".

    Returns the parsed JSON response dict, or None on failure.
    """
    url = f"{SIGNAL_REST_URL.rstrip('/')}{endpoint}"

    payload = dict(payload) if payload else {}

    if files:
        b64_attachments = []
        for fpath in files:
            fpath = expand_path(fpath)
            if not os.path.isfile(fpath):
                print(f"⚠️ Warning: attachment not found: {fpath}", file=sys.stderr)
                continue
            with open(fpath, "rb") as fh:
                encoded = base64.b64encode(fh.read()).decode("ascii")
            fname = os.path.basename(fpath)
            mime = mimetypes.guess_type(fpath)[0] or "application/octet-stream"
            # Data-URI form preserves both MIME type and filename.
            b64_attachments.append(f"data:{mime};filename={fname};base64,{encoded}")

        if b64_attachments:
            payload["base64_attachments"] = b64_attachments

    body = json.dumps(payload).encode("utf-8")
    headers = {"Content-Type": "application/json"}

    # Retry transient failures (network blips, 429, 5xx) with backoff.
    max_attempts = 3
    backoff = 2.0
    for attempt in range(1, max_attempts + 1):
        req = urllib.request.Request(url, data=body, headers=headers, method="POST")
        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                resp_data = resp.read().decode("utf-8")
                if resp_data.strip():
                    result = json.loads(resp_data)
                    if isinstance(result, dict) and "error" in result:
                        print(
                            f"❌ Signal REST error: {result['error'].strip()}",
                            file=sys.stderr,
                        )
                        return None
                    return result
                return {}
        except urllib.error.HTTPError as e:
            err_body = e.read().decode("utf-8", errors="replace")
            # Retry on rate-limit / server errors; fail fast otherwise.
            if e.code in (429, 500, 502, 503, 504) and attempt < max_attempts:
                wait = backoff * attempt
                print(
                    f"⚠️ Signal REST HTTP {e.code}; retrying in {wait:.0f}s "
                    f"(attempt {attempt}/{max_attempts})...",
                    file=sys.stderr,
                )
                time.sleep(wait)
                continue
            print(
                f"❌ Signal REST error: HTTP {e.code} — {err_body[:500]}",
                file=sys.stderr,
            )
            return None
        except urllib.error.URLError as e:
            if attempt < max_attempts:
                wait = backoff * attempt
                print(
                    f"⚠️ Cannot reach Signal REST API ({e.reason}); retrying in "
                    f"{wait:.0f}s (attempt {attempt}/{max_attempts})...",
                    file=sys.stderr,
                )
                time.sleep(wait)
                continue
            print(f"❌ Cannot reach Signal REST API at {SIGNAL_REST_URL}: {e.reason}", file=sys.stderr)
            return None
    return None


def signal_rest_get(endpoint):
    """GET a JSON response from the signal-cli-rest-api."""
    url = f"{SIGNAL_REST_URL.rstrip('/')}{endpoint}"
    req = urllib.request.Request(url, method="GET")
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            data = resp.read().decode("utf-8")
            if data.strip():
                return json.loads(data)
            return None
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", errors="replace")
        print(f"❌ Signal REST error: HTTP {e.code} — {body[:500]}", file=sys.stderr)
        return None
    except urllib.error.URLError as e:
        print(f"❌ Cannot reach Signal REST API at {SIGNAL_REST_URL}: {e.reason}", file=sys.stderr)
        return None


# ---------------------------------------------------------------------------
# Command functions
# ---------------------------------------------------------------------------

def cmd_signal(args):
    """Send via Signal using the remote REST API."""
    # --to is only required for direct messages (not group sends)
    if not args.to and not args.recipients:
        print("❌ Error: --to <phone> is required for direct messages, or use --recipients for groups.", file=sys.stderr)
        sys.exit(1)

    attach_files = getattr(args, "attach", None) or getattr(args, "file", None)
    voice_file = getattr(args, "voice", None)

    # Text, an attachment, or a voice note is required in ALL cases.
    if not args.text and not attach_files and not voice_file:
        print("❌ Error: --text <message>, --attach <file>, or --voice <audio> is required.", file=sys.stderr)
        sys.exit(1)

    account = normalize_account(args.account or SIGNAL_DEFAULT_ACCOUNT)
    message = "\n".join(args.text) if args.text else None

    # Build the list of targets. --recipients values are treated as group IDs
    # or raw recipients; --to values are direct phone numbers. Either may
    # contain more than one entry, so a single invocation can fan out.
    targets = []  # list of (recipient, group_id, label)
    if args.recipients:
        for r in args.recipients:
            if is_group_id(r):
                targets.append((None, r, f"group {r}"))
            else:
                targets.append((r, None, normalize_recipient(r)))
    if args.to:
        for t in args.to:
            targets.append((t, None, normalize_recipient(t)))

    all_ok = True
    for recipient, group_id, label in targets:
        ok = send_one_signal(
            account=account,
            recipient=recipient,
            message=message,
            group_id=group_id,
            attach=attach_files,
            voice=voice_file,
        )
        if ok:
            print(f"✅ Signal message sent to {label}")
        else:
            print(f"❌ Signal message send failed: {label}", file=sys.stderr)
            all_ok = False

    if not all_ok:
        sys.exit(1)


def cmd_sms(args):
    """Send via SMS/iMessage using imsg."""
    if not args.to:
        print("❌ Error: --to <phone> is required for SMS.", file=sys.stderr)
        sys.exit(1)
    if not args.text:
        print("❌ Error: --text <message> is required for SMS.", file=sys.stderr)
        sys.exit(1)
    if not args.service:
        print("❌ Error: --service <sms|imessage> is required for SMS.", file=sys.stderr)
        sys.exit(1)

    msg_text = "\n".join(args.text)
    attach_files = getattr(args, "file", None) or getattr(args, "attach", None)

    # imsg accepts a single --file; send the first valid one and warn on extras.
    chosen_file = None
    if attach_files:
        for f in attach_files:
            ef = expand_path(f)
            if not os.path.isfile(ef):
                print(f"⚠️ Warning: file not found, skipping: {ef}", file=sys.stderr)
                continue
            if chosen_file is None:
                chosen_file = ef
            else:
                print(f"⚠️ Warning: SMS supports one attachment; ignoring extra file: {ef}", file=sys.stderr)

    ok = send_one_sms(
        to=args.to[0],
        message=msg_text,
        service=args.service,
        file=chosen_file,
    )
    if not ok:
        sys.exit(1)


def cmd_csv(args):
    """Bulk send messages from a CSV file.

    Expected CSV columns:
        method, recipient, name, message, account, service, file, voice, delay
    """
    filepath = args.csv
    if not os.path.isfile(filepath):
        print(f"❌ Error: file not found: {filepath}", file=sys.stderr)
        sys.exit(1)

    # Global delay from --delay applies between every send unless a row
    # specifies its own delay (which takes precedence).
    global_delay = float(args.delay) if args.delay else 0.0

    with open(filepath, newline="", encoding="utf-8") as csvfile:
        reader = csv.DictReader(csvfile)
        rows = list(reader)

    if not rows:
        print("❌ Error: CSV file is empty (no data rows).", file=sys.stderr)
        sys.exit(1)

    # Count the rows that will actually be attempted (non-blank).
    actionable = [
        r for r in rows
        if (r.get("method", "").strip()
            or r.get("message", "").strip()
            or r.get("recipient", "").strip()
            or r.get("file", "").strip()
            or r.get("voice", "").strip())
    ]

    if args.dry_run:
        print(f"🔎 Dry run: {len(actionable)} row(s) would be processed from {filepath}\n")
    elif len(actionable) > CSV_CONFIRM_THRESHOLD and not args.yes:
        try:
            resp = input(
                f"⚠️ About to send {len(actionable)} messages. Continue? [y/N] "
            ).strip().lower()
        except EOFError:
            resp = "n"
        if resp not in ("y", "yes"):
            print("Aborted.")
            sys.exit(1)

    total = len(rows)
    success = 0
    failed = 0
    skipped = 0
    previewed = 0
    errors = []

    for i, row in enumerate(rows, 1):
        method = row.get("method", "").strip().lower()
        message = row.get("message", "").strip()
        to = row.get("recipient", "").strip()
        name = row.get("name", "").strip()
        voice_path = row.get("voice", "").strip() or None

        # Skip blank rows
        if not method and not message and not to and not voice_path:
            continue

        if not method:
            method = "signal"  # default

        if method not in ("signal", "sms"):
            print(f"⚠️ Row {i}: unknown method '{method}' — skipping", file=sys.stderr)
            skipped += 1
            errors.append(f"Row {i}: unknown method '{method}'")
            continue

        # Common fields
        account = row.get("account", "").strip() or SIGNAL_DEFAULT_ACCOUNT
        service = row.get("service", "").strip().lower() or None
        file_path = row.get("file", "").strip() or None
        delay_str = row.get("delay", "").strip()
        try:
            row_delay = float(delay_str) if delay_str else None
        except ValueError:
            print(f"⚠️ Row {i}: invalid delay '{delay_str}' — ignoring", file=sys.stderr)
            row_delay = None

        # A message is required UNLESS there is an attachment or a voice note
        # (Signal can send attachment-only / voice-only messages). SMS still
        # requires text.
        has_signal_media = method == "signal" and (file_path or voice_path)
        if not message and not has_signal_media:
            if method == "sms":
                reason = "empty message (SMS requires text)"
            else:
                reason = "empty message (no text, attachment, or voice)"
            print(f"⚠️ Row {i}: {reason} — skipping", file=sys.stderr)
            skipped += 1
            errors.append(f"Row {i}: {reason}")
            continue

        if method == "sms":
            if voice_path:
                print(
                    f"⚠️ Row {i}: voice notes are only supported for Signal "
                    f"(method 'signal') — skipping",
                    file=sys.stderr,
                )
                skipped += 1
                errors.append(f"Row {i}: voice not supported for SMS")
                continue
            if service not in ("sms", "imessage"):
                print(
                    f"⚠️ Row {i}: SMS requires service 'sms' or 'imessage' "
                    f"(got '{service or ''}') — skipping",
                    file=sys.stderr,
                )
                skipped += 1
                errors.append(f"Row {i}: invalid/missing SMS service")
                continue

        label = f"{name} ({to})" if name else to or "(group)"
        prefix = "[DRY] " if args.dry_run else ""
        print(f"{prefix}[{i}/{total}] {method.upper()} → {label}")

        if args.dry_run:
            # Report what would happen without sending.
            extras = []
            if file_path:
                extras.append(f"file={file_path}")
            if voice_path:
                extras.append(f"voice={voice_path}")
            if method == "signal" and is_group_id(to):
                extras.append("group")
            if method == "sms":
                extras.append(f"service={service}")
            if extras:
                print(f"        ({', '.join(extras)})")
            previewed += 1
            continue

        if method == "signal":
            # Detect groups robustly (issue #3): raw base64 group IDs do NOT
            # start with 'group.' and were previously sent down the 1:1
            # recipient path, producing 'Unregistered user <uuid>' errors.
            group_id = to if is_group_id(to) else None
            result = send_one_signal(
                account=account,
                recipient=to,
                message=message,
                group_id=group_id,
                attach=file_path,
                voice=voice_path,
            )
        else:  # sms
            result = send_one_sms(
                to=normalize_account(to),
                message=message,
                service=service,
                file=file_path,
            )

        if result:
            success += 1
        else:
            failed += 1
            errors.append(f"Row {i}: failed to send")

        # Per-row delay overrides global; otherwise use the global --delay.
        effective_delay = row_delay if row_delay is not None else global_delay
        if effective_delay > 0 and not args.dry_run:
            time.sleep(effective_delay)

    # Summary
    if getattr(args, "json_output", False):
        summary = {
            "total": total,
            "success": success,
            "skipped": skipped,
            "failed": failed,
            "dry_run": bool(args.dry_run),
            "notes": errors,
        }
        if args.dry_run:
            summary["would_send"] = previewed
        print(json.dumps(summary, indent=2))
    else:
        print()
        print("=" * 50)
        print("📊 CSV Send Summary" + (" (DRY RUN)" if args.dry_run else ""))
        print("=" * 50)
        print(f"  Total rows:   {total}")
        if args.dry_run:
            print(f"  🔎 Would send: {previewed}")
        else:
            print(f"  ✅ Success:    {success}")
        print(f"  ⏭️  Skipped:    {skipped}")
        print(f"  ❌ Failed:     {failed}")
        if errors:
            print()
            print("  Notes:")
            for e in errors:
                print(f"    • {e}")
        print("=" * 50)

    if failed > 0:
        sys.exit(1)


def cmd_show_config(args):
    """Report the resolved configuration and where each value came from."""
    sources = CFG.get("_sources", {})
    config_path = CFG.get("_config_path")

    print("=" * 50)
    print("⚙️  sendmsg configuration")
    print("=" * 50)
    print(f"  Version:        {VERSION}")
    print()

    if config_path:
        print(f"  Config file:    {config_path} (found)")
    else:
        searched = ", ".join(CONFIG_PATHS)
        print(f"  Config file:    none found (searched: {searched})")
    print()

    print(f"  signal_rest_url:        {SIGNAL_REST_URL}")
    print(f"    └─ source:            {sources.get('signal_rest_url', 'unknown')}")
    print()
    print(f"  signal_default_account: {SIGNAL_DEFAULT_ACCOUNT}")
    print(f"    └─ source:            {sources.get('signal_default_account', 'unknown')}")
    print()

    print("  Resolution order: env var > config file > built-in default")
    print("=" * 50)

    # Optionally probe whether the Signal REST API is reachable.
    if getattr(args, "verbose", False):
        print()
        print("  Checking Signal REST API reachability...")
        result = signal_rest_get("/v1/accounts")
        if result is None:
            print(f"  ❌ Not reachable at {SIGNAL_REST_URL}")
        else:
            print(f"  ✅ Reachable at {SIGNAL_REST_URL}")
        print("=" * 50)


def _extract_accounts(result):
    """Normalize the various shapes /v1/accounts may return into a list of str.

    The REST API has returned a bare list, a {'accounts': [...]} dict, and a
    single {'number': ...} dict across versions. Returns [] for None/unknown.
    """
    if result is None:
        return []
    if isinstance(result, list):
        return [str(a) for a in result]
    if isinstance(result, dict):
        if "accounts" in result and isinstance(result["accounts"], list):
            return [str(a) for a in result["accounts"]]
        if "number" in result:
            return [str(result["number"])]
    return []


def cmd_list_signal(args):
    """List linked Signal accounts via the REST API."""
    result = signal_rest_get("/v1/accounts")
    if result is None:
        print(f"❌ Failed to list accounts (cannot reach {SIGNAL_REST_URL})", file=sys.stderr)
        sys.exit(1)

    accounts = _extract_accounts(result)

    if not accounts:
        print("No Signal accounts linked. Use --link-signal to set one up.")
    else:
        print("Linked Signal accounts:")
        for acct in accounts:
            print(f"  • {acct}")


def cmd_link_signal(args):
    """Link a new Signal device using the sgnl:// URL method.

    The signal-cli-rest-api supports device linking via its /v1/link endpoint.
    This prints the sgnl:// URL to stdout, then waits for the user to scan/confirm.
    """
    name = args.name or "automation"

    # Snapshot existing accounts so we can detect a genuinely NEW link rather
    # than reporting success just because a primary account already exists.
    before = signal_rest_get("/v1/accounts")
    before_set = set(_extract_accounts(before))

    payload = {"name": name}
    result = signal_rest_post("/v1/link", payload=payload)
    if result is None:
        print("❌ Failed to initiate device linking.", file=sys.stderr)
        sys.exit(1)

    sgnl_url = ""
    if isinstance(result, dict):
        sgnl_url = result.get("url", result.get("linkUrl", ""))
    if not sgnl_url:
        print("❌ No sgnl:// URL returned. API response:", file=sys.stderr)
        print(json.dumps(result, indent=2), file=sys.stderr)
        sys.exit(1)

    print("🔗 Scan this QR code or visit this URL to link your device:")
    print(f"   {sgnl_url}")
    print()
    print("Waiting for confirmation (Ctrl+C to cancel)...")

    start = time.time()
    while time.time() - start < 120:
        time.sleep(3)
        current_set = set(_extract_accounts(signal_rest_get("/v1/accounts")))
        new_accounts = current_set - before_set
        if new_accounts:
            print(f"✅ Device linked! New account(s): {', '.join(sorted(new_accounts))}")
            return

    print("⚠️ Linking timed out (no new account detected). Please try again.", file=sys.stderr)


# ---------------------------------------------------------------------------
# Main / argument parsing
# ---------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(
        prog="sendmsg",
        description="Universal message sender for Signal (REST API) and SMS/iMessage",
        epilog=(
            "Examples:\n"
            "  %(prog)s --signal --to +18005551212 --text 'Hello!'\n"
            "  %(prog)s --signal --recipients GROUP_ID --text 'Hello group!'\n"
            "  %(prog)s --signal --to +18005551212 --voice ~/note.m4a\n"
            "  %(prog)s --sms --to +18005551212 --text 'Text' --service sms\n"
            "  %(prog)s --sms --to +18005551212 --text 'Hey' --service imessage --file ~/pic.jpg\n"
            "  %(prog)s --csv messages.csv\n"
            "  %(prog)s --csv messages.csv --delay 2\n"
            "  %(prog)s --csv messages.csv --dry-run\n"
            "  %(prog)s --list-signal\n"
            "  %(prog)s --link-signal --name 'my-automation'\n"
            "\n"
            "CSV columns: method, recipient, name, message, account, service, file, voice, delay\n"
            "The 'name' field is printed in the status output during CSV sends.\n"
            "For SMS rows the 'service' column must be 'sms' or 'imessage'.\n"
            f"Signal REST API: {SIGNAL_REST_URL}\n"
            "Config file: ~/.sendmsg.conf\n"
            "  [settings]\n"
            "  signal_rest_url = http://localhost:8080\n"
            "  signal_default_account = +1888222333\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )

    parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {VERSION}")

    # Method
    method = parser.add_argument_group("Messaging method (choose one)")
    method.add_argument("--signal", action="store_true", help="Send via Signal")
    method.add_argument("--sms", action="store_true", help="Send via SMS/iMessage (macOS Messages)")
    method.add_argument("--csv", metavar="FILE", help="Bulk send from a CSV file")

    # Common options
    parser.add_argument("--to", nargs="+", metavar="PHONE", help="Recipient phone number(s)")
    parser.add_argument("--text", nargs="+", metavar="MSG", help="Message text (repeat for multiple)")
    parser.add_argument("--file", "--attach", nargs="+", metavar="PATH", dest="file",
                        help="Attachment file path(s)")
    parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")

    # Signal-specific
    signal_grp = parser.add_argument_group("Signal options")
    signal_grp.add_argument("--account", metavar="PHONE", help="Signal account phone number to use")
    signal_grp.add_argument("--recipients", nargs="+", metavar="RECIPIENT",
                            help="Send to one or more Signal group IDs / recipients (no --to needed)")
    signal_grp.add_argument("--voice", metavar="AUDIO", help="Send an audio file as a Signal voice message")
    signal_grp.add_argument("--name", metavar="NAME", help="Device name for Signal linking (default: 'automation')")

    # Management commands
    mgmt = parser.add_argument_group("Management commands")
    mgmt.add_argument("--list-signal", action="store_true", help="List linked Signal accounts")
    mgmt.add_argument("--link-signal", action="store_true", help="Link a new device to your Signal account")
    mgmt.add_argument("--show-config", action="store_true",
                      help="Print resolved configuration and where each value came from")

    # SMS-specific
    sms_grp = parser.add_argument_group("SMS/iMessage options")
    sms_grp.add_argument("--service", choices=["imessage", "sms"],
                         help="Service to use: 'imessage' or 'sms' (required for --sms)")

    # CSV-specific
    csv_grp = parser.add_argument_group("CSV options")
    csv_grp.add_argument("--delay", metavar="SECONDS", help="Wait N seconds between CSV row sends (default: 0)")
    csv_grp.add_argument("--dry-run", action="store_true", help="Preview a CSV batch without sending")
    csv_grp.add_argument("--yes", "-y", action="store_true",
                         help="Skip the large-batch confirmation prompt")
    csv_grp.add_argument("--json", action="store_true", dest="json_output",
                         help="Emit a machine-readable JSON summary (for unattended runs)")

    args = parser.parse_args()

    # cmd_signal still reads args.attach in places; keep an alias.
    args.attach = args.file

    # Validate: must have exactly one method or management command.
    methods = {
        "--signal": args.signal,
        "--sms": args.sms,
        "--csv": bool(args.csv),
        "--list-signal": args.list_signal,
        "--link-signal": args.link_signal,
        "--show-config": args.show_config,
    }
    chosen = [name for name, on in methods.items() if on]

    if not chosen:
        parser.print_help()
        print("\n❌ Error: Specify --signal, --sms, --csv, --list-signal, --link-signal, or --show-config", file=sys.stderr)
        sys.exit(1)

    if len(chosen) > 1:
        print(f"❌ Error: Choose only one action (got: {', '.join(chosen)}).", file=sys.stderr)
        sys.exit(1)

    # --voice is single-file only.
    if args.voice and args.file and len(args.file) > 0:
        # Allow voice + attachments, but warn it's two separate sends.
        print("ℹ️  Note: --voice and --attach will be sent as separate messages.", file=sys.stderr)

    # Set defaults
    if args.link_signal and not args.name:
        args.name = "automation"

    # Dispatch
    if args.signal:
        cmd_signal(args)
    elif args.sms:
        cmd_sms(args)
    elif args.csv:
        cmd_csv(args)
    elif args.list_signal:
        cmd_list_signal(args)
    elif args.link_signal:
        cmd_link_signal(args)
    elif args.show_config:
        cmd_show_config(args)


if __name__ == "__main__":
    main()
