#!/usr/bin/env python3
"""Local macOS SMS OTP helper for GPC GoPay flows.

Run this on the Mac that receives forwarded iPhone SMS messages. The helper
reads a copy of the macOS Messages database and exposes the latest matching OTP
on a localhost HTTP endpoint for the Chrome extension.
"""

from __future__ import annotations

import argparse
import datetime as dt
import json
import os
import re
import shutil
import sqlite3
import sys
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Optional
from urllib.parse import parse_qs, urlparse

HOST = "127.0.0.1"
PORT = 18767
DEFAULT_DB = "~/Library/Messages/chat.db"
MAC_ABSOLUTE_EPOCH = dt.datetime(2001, 1, 1, tzinfo=dt.timezone.utc)

OTP_PATTERNS = (
    re.compile(r"(?i)\bOTP\s*[:：]?\s*([0-9]{4,8})\b"),
    re.compile(r"#([0-9]{4,8})\b"),
    re.compile(r"(?<!\d)([0-9]{6})(?!\d)"),
)
KEYWORDS = ("gojek", "gopay", "openai llc", "openai")

STATE_LOCK = threading.Lock()
STATE = {
    "started_at": dt.datetime.now(dt.timezone.utc).isoformat(),
    "db_path": "",
    "last_scan_at": "",
    "last_error": "",
    "last_rowid": 0,
    "last_otp": None,
    "otps": [],
}


def is_macos() -> bool:
    override = os.environ.get("GPC_SMS_HELPER_ALLOW_NON_MAC", "").strip().lower()
    return sys.platform == "darwin" or override in {"1", "true", "yes"}


def require_macos() -> None:
    if not is_macos():
        raise RuntimeError(
            "GPC 本地 SMS Helper 仅支持 macOS：需要读取 ~/Library/Messages/chat.db。"
            "请确认接收验证码的 iPhone 已开启短信转发，并能在 Mac 信息 app 中看到短信。"
        )


def extract_gopay_otp(text: str, require_keywords: bool = True) -> Optional[str]:
    raw = text or ""
    lowered = raw.lower()
    if require_keywords and not any(keyword in lowered for keyword in KEYWORDS):
        return None
    for pattern in OTP_PATTERNS:
        match = pattern.search(raw)
        if match:
            return match.group(1)
    return None


def normalize_phone_key(value: object) -> str:
    digits = re.sub(r"\D+", "", str(value or ""))
    return f"+{digits}" if digits else ""


def mac_message_time_to_datetime(value: int | float | None) -> dt.datetime:
    if not value:
        return dt.datetime.now(dt.timezone.utc)
    seconds = float(value)
    if seconds > 10_000_000_000:
        seconds = seconds / 1_000_000_000
    return MAC_ABSOLUTE_EPOCH + dt.timedelta(seconds=seconds)


def update_state(**updates: object) -> None:
    with STATE_LOCK:
        STATE.update(updates)


def get_state() -> dict:
    with STATE_LOCK:
        return dict(STATE)


def copy_messages_db(db_path: Path) -> Path:
    if not db_path.exists():
        raise FileNotFoundError(f"Messages 数据库不存在：{db_path}")
    tmpdir = Path(tempfile.mkdtemp(prefix="gpc_messages_"))
    copied = tmpdir / "chat.db"
    shutil.copy2(db_path, copied)
    wal = db_path.with_name(db_path.name + "-wal")
    shm = db_path.with_name(db_path.name + "-shm")
    if wal.exists():
        shutil.copy2(wal, copied.with_name("chat.db-wal"))
    if shm.exists():
        shutil.copy2(shm, copied.with_name("chat.db-shm"))
    return copied


def read_recent_messages(db_path: Path, after_rowid: int = 0, limit: int = 80) -> list[dict]:
    copied = copy_messages_db(db_path)
    try:
        conn = sqlite3.connect(str(copied))
        conn.row_factory = sqlite3.Row
        params = (max(0, int(after_rowid or 0)), max(1, int(limit or 80)))
        try:
            rows = conn.execute(
                """
                SELECT
                  message.ROWID AS rowid,
                  message.guid AS guid,
                  message.text AS text,
                  message.date AS date,
                  message.service AS service,
                  message.destination_caller_id AS destination_caller_id,
                  message.account AS account,
                  handle.id AS handle,
                  chat.last_addressed_handle AS last_addressed_handle,
                  chat.account_login AS account_login
                FROM message
                LEFT JOIN handle ON message.handle_id = handle.ROWID
                LEFT JOIN chat_message_join ON chat_message_join.message_id = message.ROWID
                LEFT JOIN chat ON chat.ROWID = chat_message_join.chat_id
                WHERE message.ROWID > ?
                  AND message.text IS NOT NULL
                ORDER BY message.ROWID DESC
                LIMIT ?
                """,
                params,
            ).fetchall()
        except sqlite3.OperationalError:
            rows = conn.execute(
                """
                SELECT
                  message.ROWID AS rowid,
                  message.guid AS guid,
                  message.text AS text,
                  message.date AS date,
                  message.service AS service,
                  message.account AS account,
                  handle.id AS handle
                FROM message
                LEFT JOIN handle ON message.handle_id = handle.ROWID
                WHERE message.ROWID > ?
                  AND message.text IS NOT NULL
                ORDER BY message.ROWID DESC
                LIMIT ?
                """,
                params,
            ).fetchall()
        return [dict(row) for row in rows]
    finally:
        try:
            conn.close()  # type: ignore[name-defined]
        except Exception:
            pass
        shutil.rmtree(copied.parent, ignore_errors=True)


def get_record_phone(row: dict) -> str:
    for key in ("destination_caller_id", "last_addressed_handle", "account_login", "account"):
        phone = normalize_phone_key(row.get(key))
        if phone:
            return phone
    return ""


def make_otp_record(row: dict, otp: str) -> dict:
    received_at = mac_message_time_to_datetime(row.get("date")).isoformat()
    phone_e164 = get_record_phone(row)
    return {
        "otp": otp,
        "code": otp,
        "message_id": str(row.get("guid") or row.get("rowid") or ""),
        "rowid": int(row.get("rowid") or 0),
        "sender": str(row.get("handle") or ""),
        "service": str(row.get("service") or ""),
        "account_phone": phone_e164,
        "phone_e164": phone_e164,
        "received_at": received_at,
        "message_text": str(row.get("text") or ""),
    }


def append_otp(record: dict, max_records: int = 30) -> None:
    with STATE_LOCK:
        records = [item for item in STATE.get("otps", []) if item.get("message_id") != record.get("message_id")]
        records.insert(0, record)
        STATE["otps"] = records[:max_records]
        STATE["last_otp"] = record
        STATE["last_rowid"] = max(int(STATE.get("last_rowid") or 0), int(record.get("rowid") or 0))


def parse_timestamp_ms(value: object) -> int:
    if value is None:
        return 0
    if isinstance(value, (int, float)):
        numeric = float(value)
    else:
        raw = str(value).strip()
        if not raw:
            return 0
        try:
            numeric = float(raw)
        except ValueError:
            try:
                parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
                if parsed.tzinfo is None:
                    parsed = parsed.replace(tzinfo=dt.timezone.utc)
                return int(parsed.timestamp() * 1000)
            except ValueError:
                return 0
    if numeric <= 0:
        return 0
    if numeric < 100_000_000_000:
        numeric *= 1000
    return int(numeric)


def record_matches_phone(record: dict, phone: str = "") -> bool:
    wanted = normalize_phone_key(phone)
    if not wanted:
        return True
    return normalize_phone_key(record.get("phone_e164") or record.get("account_phone")) == wanted


def select_otp_record(state: dict, after_ms: int = 0, phone: str = "") -> Optional[dict]:
    records = state.get("otps")
    if not isinstance(records, list):
        records = []
    if after_ms > 0:
        for record in records:
            if (
                isinstance(record, dict)
                and record_matches_phone(record, phone)
                and parse_timestamp_ms(record.get("received_at")) >= after_ms
            ):
                return record
        return None
    record = state.get("last_otp") or None
    if isinstance(record, dict) and record_matches_phone(record, phone):
        return record
    for record in records:
        if isinstance(record, dict) and record_matches_phone(record, phone):
            return record
    return None


def consume_otp_record(phone: str = "", record: Optional[dict] = None) -> None:
    wanted = normalize_phone_key(phone)
    consumed_message_id = str((record or {}).get("message_id") or "").strip()
    consumed_rowid = int((record or {}).get("rowid") or 0)

    def is_consumed_record(item: object) -> bool:
        if not isinstance(item, dict):
            return False
        if consumed_message_id and str(item.get("message_id") or "").strip() == consumed_message_id:
            return True
        if consumed_rowid and int(item.get("rowid") or 0) == consumed_rowid:
            return True
        return False

    def is_same_record(left: object, right: object) -> bool:
        if not isinstance(left, dict) or not isinstance(right, dict):
            return False
        left_message_id = str(left.get("message_id") or "").strip()
        right_message_id = str(right.get("message_id") or "").strip()
        if left_message_id and right_message_id and left_message_id == right_message_id:
            return True
        left_rowid = int(left.get("rowid") or 0)
        right_rowid = int(right.get("rowid") or 0)
        if left_rowid > 0 and right_rowid > 0 and left_rowid == right_rowid:
            return True
        return left == right

    with STATE_LOCK:
        records = STATE.get("otps")
        if not isinstance(records, list):
            records = []

        if consumed_message_id or consumed_rowid:
            next_records = [item for item in records if isinstance(item, dict) and not is_consumed_record(item)]
        elif wanted:
            removed_once = False
            next_records = []
            for item in records:
                if not isinstance(item, dict):
                    continue
                if not removed_once and record_matches_phone(item, wanted):
                    removed_once = True
                    continue
                next_records.append(item)
        else:
            next_records = []

        STATE["otps"] = next_records
        last_otp = STATE.get("last_otp")
        if isinstance(last_otp, dict) and not any(is_same_record(last_otp, item) for item in STATE["otps"]):
            STATE["last_otp"] = STATE["otps"][0] if STATE["otps"] else None


def scan_once(db_path: Path, require_keywords: bool = True) -> None:
    state = get_state()
    after_rowid = int(state.get("last_rowid") or 0)
    rows = read_recent_messages(db_path, after_rowid=after_rowid)
    max_rowid = after_rowid
    for row in reversed(rows):
        max_rowid = max(max_rowid, int(row.get("rowid") or 0))
        otp = extract_gopay_otp(row.get("text") or "", require_keywords=require_keywords)
        if otp:
            record = make_otp_record(row, otp)
            append_otp(record)
            print(f"captured OTP {otp} from message {record['message_id']} at {record['received_at']}", flush=True)
    update_state(last_rowid=max_rowid, last_scan_at=dt.datetime.now(dt.timezone.utc).isoformat(), last_error="")


def scan_loop(db_path: Path, interval_seconds: float, require_keywords: bool) -> None:
    update_state(db_path=str(db_path))
    while True:
        try:
            scan_once(db_path, require_keywords=require_keywords)
        except Exception as exc:
            update_state(last_error=str(exc), last_scan_at=dt.datetime.now(dt.timezone.utc).isoformat())
            print(f"scan error: {exc}", file=sys.stderr, flush=True)
        time.sleep(max(0.5, float(interval_seconds or 2)))


def write_json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
    body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
    handler.send_response(status)
    handler.send_header("Content-Type", "application/json; charset=utf-8")
    handler.send_header("Access-Control-Allow-Origin", "*")
    handler.send_header("Cache-Control", "no-store")
    handler.send_header("Content-Length", str(len(body)))
    handler.end_headers()
    handler.wfile.write(body)


class HelperHandler(BaseHTTPRequestHandler):
    server_version = "GpcSmsHelper/1.0"

    def log_message(self, fmt: str, *args: object) -> None:
        print(f"{self.address_string()} - {fmt % args}", flush=True)

    def do_GET(self) -> None:  # noqa: N802
        parsed = urlparse(self.path)
        if parsed.path == "/health":
            state = get_state()
            write_json(self, 200, {
                "ok": True,
                "status": "ok",
                "has_otp": bool(state.get("last_otp")),
                "last_scan_at": state.get("last_scan_at"),
                "last_error": state.get("last_error"),
            })
            return
        if parsed.path in ("/otp", "/latest-otp"):
            query = parse_qs(parsed.query)
            consume = str(query.get("consume", ["0"])[0]).strip().lower() in {"1", "true", "yes"}
            after_ms = parse_timestamp_ms(query.get("after_ms", query.get("after", ["0"]))[0])
            phone = str((query.get("phone") or query.get("phone_e164") or query.get("phone_number") or [""])[0]).strip()
            state = get_state()
            record = select_otp_record(state, after_ms=after_ms, phone=phone)
            if not record:
                write_json(self, 200, {"ok": True, "otp": "", "code": "", "status": "waiting", "message": "未查询到验证码"})
                return
            payload = {"ok": True, "status": "found", **record}
            if consume:
                consume_otp_record(phone=phone, record=record)
            write_json(self, 200, payload)
            return
        write_json(self, 404, {"ok": False, "error": "not_found"})


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Run a local macOS Messages HTTP helper for GPC SMS OTP.")
    parser.add_argument("--host", default=HOST, help="Bind host, default 127.0.0.1")
    parser.add_argument("--port", type=int, default=PORT, help="Bind port, default 18767")
    parser.add_argument("--db", default=DEFAULT_DB, help="Path to macOS Messages chat.db")
    parser.add_argument("--interval", type=float, default=2.0, help="Message scan interval in seconds")
    parser.add_argument("--no-keywords", action="store_true", help="Accept any numeric OTP without GPC/OpenAI keywords")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    try:
        require_macos()
        db_path = Path(os.path.expanduser(args.db)).resolve()
        scanner = threading.Thread(
            target=scan_loop,
            args=(db_path, args.interval, not args.no_keywords),
            daemon=True,
        )
        scanner.start()
        server = ThreadingHTTPServer((args.host, int(args.port)), HelperHandler)
        print(f"GPC SMS Helper listening on http://{args.host}:{int(args.port)}", flush=True)
        print("请确认 iPhone 短信已转发到本机 Messages。", flush=True)
        server.serve_forever()
        return 0
    except KeyboardInterrupt:
        return 0
    except Exception as exc:
        print(str(exc), file=sys.stderr, flush=True)
        return 1


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