#!/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!"
    sendmsg --sms --to +18005551212 --text "Hey" --file ~/photo.png
    sendmsg --signal --to +18005551212 --text "Multi" --text "more text"
    sendmsg --signal --to +18005551212 --attach ~/doc.pdf ~/pic.jpg
    sendmsg --csv messages.csv             # Bulk send from CSV file
    sendmsg --csv messages.csv --delay 2   # Wait N seconds between sends
    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,delay
    signal,+1888222333,Alice,Hello via Signal,+1888222333,,,
    signal,group.ZzBHd3NZO...,Group alert,Hi team,,,,
    sms,+1234567890,Bob,SMS test,,,~/pic.jpg,
    signal,+1888222333,Carol,With delay,,,,3
"""

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

VERSION = "4.3.0"

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


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"]


# ---------------------------------------------------------------------------
# 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 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
    # Phone numbers start with '+' followed by digits. Anything else that
    # is not a phone number is assumed to be a (raw) group ID.
    if value.startswith("+") and value[1:].isdigit():
        return False
    return True


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

    payload = {"number": account}
    if group_id:
        payload["recipients"] = [group_id]
    else:
        payload["recipients"] = [recipient]

    if message:
        payload["message"] = message

    # Expand '~' / env vars; CSV supplies literal paths the shell never saw.
    attach = expand_path(attach) if attach else None

    # If an attachment was specified but does not exist, warn loudly rather
    # than silently sending text-only.
    if attach and not os.path.isfile(attach):
        print(f"⚠️ Warning: attachment not found, sending without it: {attach}", file=sys.stderr)
        attach = None

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

    if has_attachment:
        result = signal_rest_post("/v2/send", payload=payload, files=[attach])
    else:
        result = signal_rest_post("/v2/send", payload=payload)

    return result is not None


def send_one_sms(to, message, service=None, file=None):
    """Send a single SMS/iMessage. Returns True on success."""
    cmd = ["imsg", "send", "--to", to]
    if service:
        cmd.extend(["--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)

    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode == 0:
        service_label = service.upper() if service else "AUTO"
        print(f"✅ SMS/iMessage sent to {to} (service: {service_label})")
        return True
    else:
        print(f"❌ SMS send failed (exit {result.returncode}): {result.stderr.strip()}", file=sys.stderr)
        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"}
    full_body = body

    req = urllib.request.Request(url, data=full_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:
        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


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)

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

    account = normalize_account(args.account or SIGNAL_DEFAULT_ACCOUNT)

    # Build the JSON payload for /v2/send
    payload = {"number": account}
    if args.recipients:
        # Send to a Signal group — use recipients array with the group ID.
        payload["recipients"] = [args.recipients]
    else:
        # Send to an individual recipient. The REST API accepts a
        # recipients array for direct messages too; using it consistently
        # avoids the 1:1-vs-group dispatch mismatch behind issue #3.
        payload["recipients"] = [args.to[0]]

    # Message text (join multiple --text args)
    if args.text:
        payload["message"] = "\n".join(args.text)

    # Attachments
    if attach_files and len(attach_files) > 0:
        if len(attach_files) == 1:
            # Single file — send as multipart with the message.
            result = signal_rest_post("/v2/send", payload=payload, files=attach_files)
        else:
            # Multiple files: send the text first, then each attachment.
            multi_payload = {"number": account, "message": payload.get("message", "")}
            multi_payload["recipients"] = payload["recipients"]

            # Only send a standalone text message if there is actual text.
            result = None
            if multi_payload["message"]:
                result = signal_rest_post("/v2/send", payload=multi_payload)
                if result is None:
                    sys.exit(1)

            # Then send each attachment.
            for fpath in attach_files:
                att_payload = {"number": account, "recipients": payload["recipients"]}
                att_result = signal_rest_post("/v2/send", payload=att_payload, files=[fpath])
                if att_result is None:
                    print(f"⚠️ Attachment failed: {fpath}", file=sys.stderr)
                else:
                    result = att_result
    else:
        # Text-only send (direct or group).
        result = signal_rest_post("/v2/send", payload=payload)

    if result is not None:
        if args.recipients:
            print(f"✅ Signal message sent to group {args.recipients}")
        elif args.to:
            print(f"✅ Signal message sent to {args.to[0]}")
        else:
            print("✅ Signal message sent")
        if args.verbose and isinstance(result, dict):
            for key, value in result.items():
                if key not in ("success",):
                    print(f"   {key}: {value}")
    else:
        print("❌ Signal message send failed.", file=sys.stderr)
        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)

    cmd = ["imsg", "send", "--to", args.to[0]]
    if args.service:
        cmd.extend(["--service", args.service])
    msg_text = "\n".join(args.text)
    cmd.extend(["--text", msg_text])

    attach_files = getattr(args, "file", None) or getattr(args, "attach", None)
    if attach_files:
        for f in attach_files:
            f = expand_path(f)
            if not os.path.isfile(f):
                print(f"⚠️ Warning: file not found, skipping: {f}", file=sys.stderr)
                continue
            cmd.extend(["--file", f])

    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode == 0:
        service_label = args.service.upper() if args.service else "AUTO"
        print(f"✅ SMS/iMessage sent to {args.to[0]} (service: {service_label})")
    else:
        print(f"❌ SMS send failed (exit {result.returncode})", file=sys.stderr)
        if result.stderr:
            print(f"   {result.stderr.strip()}", file=sys.stderr)
        sys.exit(1)


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

    Expected CSV columns:
        method, recipient, name, message, account, service, file, 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)

    total = len(rows)
    success = 0
    failed = 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()

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

        if not method:
            method = "signal"  # default

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

        if not message:
            print(f"⚠️ Row {i}: empty message — skipping", file=sys.stderr)
            failed += 1
            errors.append(f"Row {i}: empty message")
            continue

        # Common fields
        account = row.get("account", "").strip() or SIGNAL_DEFAULT_ACCOUNT
        service = row.get("service", "").strip() 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

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

        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,
            )
        else:  # sms
            result = send_one_sms(
                to=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:
            time.sleep(effective_delay)

    # Summary
    print()
    print("=" * 50)
    print("📊 CSV Send Summary")
    print("=" * 50)
    print(f"  Total rows:   {total}")
    print(f"  ✅ Success:    {success}")
    print(f"  ❌ Failed:     {failed}")
    if errors:
        print()
        print("  Errors:")
        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 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)

    if isinstance(result, list):
        accounts = [str(a) for a in result]
    elif isinstance(result, dict) and "accounts" in result:
        accounts = result["accounts"]
    elif isinstance(result, dict) and "number" in result:
        accounts = [result["number"]]
    else:
        accounts = [str(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"
    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)
        result = signal_rest_get("/v1/accounts")
        if result and (isinstance(result, list) and len(result) > 0):
            print(f"✅ Device linked! Account: {result}")
            return

    print("⚠️ Linking timed out. 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 --sms --to +18005551212 --text 'Text via SMS'\n"
            "  %(prog)s --sms --to +18005551212 --text 'Hey' --file ~/pic.jpg\n"
            "  %(prog)s --csv messages.csv\n"
            "  %(prog)s --csv messages.csv --delay 2\n"
            "  %(prog)s --list-signal\n"
            "  %(prog)s --link-signal\n"
            "  %(prog)s --link-signal --name 'my-automation'\n"
            "\n"
            "CSV columns: method, recipient, name, message, account, service, file, delay\n"
            "The 'name' field is printed in the status output during CSV sends.\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", metavar="RECIPIENTS", help="Send to Signal group ID (no --to needed)")
    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", "auto"], help="Force service (default: auto-detect)")

    # 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)")

    args = parser.parse_args()

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

    # Validate: must have a method or management command
    if not any([args.signal, args.sms, args.csv, args.list_signal, args.link_signal, args.show_config]):
        parser.print_help()
        print("\n❌ Error: Specify --signal, --sms, --csv, --list-signal, --link-signal, or --show-config", file=sys.stderr)
        sys.exit(1)

    if sum([bool(args.signal), bool(args.sms), bool(args.csv)]) > 1:
        print("❌ Error: Choose only one method (--signal, --sms, or --csv).", file=sys.stderr)
        sys.exit(1)

    # 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()
