"""
otp_service.py — OTPService: single-responsibility service for IMAP watching.

Responsibilities:
  - Connect to Gmail via IMAP IDLE
  - Detect OTP emails
  - Extract codes
  - Fire a callback: on_otp(code, app_name, subject)

Does NOT touch the clipboard, notifications, UI, or config — those belong
to their own layers (app.py and otp_watcher.py respectively).
"""

import email
import re
import threading
import time
from email.header import decode_header
from datetime import datetime, date

try:
    from imapclient import IMAPClient
except ImportError:
    raise SystemExit("ERROR: imapclient not installed. Run: pip3 install imapclient")


# ── Constants ─────────────────────────────────────────────────────────────────

IMAP_HOST = "imap.gmail.com"

_CODE = r"([A-Z0-9]{4,10})"

_OTP_PATTERNS = [
    r"(?:use\s+this\s+)?(?:code|OTP|passcode|pin|token)\s*(?:to\s+\S+\s+\S+\s*)?[:\-=]\s*" + _CODE,
    _CODE + r"\s+is\s+your\s+(?:verification|login|sign[\-\s]?in|authentication|one[\-\s]?time|OTP|security)\s*(?:code|password|passcode|pin)",
    r"(?:your\s+)?(?:verification\s+)?(?:code|OTP|one[\-\s]?time[\-\s]?(?:password|code)|passcode|pin|token)\s+(?:is|:|=)\s*" + _CODE,
    r"(?:use|enter|provide|input)\s+(?:the\s+)?(?:code|OTP|pin|passcode)\s+" + _CODE,
    r"code\s+is\s+" + _CODE,
    r"(?:^|\n)\s*([A-Z0-9]{5,8})\s*(?:\n|$)",
    r"(?<![/\d])(\d{6})(?![/\d])",
]

_OTP_KEYWORDS = [
    "verification code", "verify your", "one-time", "one time password",
    "otp", "login code", "sign-in code", "sign in code", "security code",
    "authentication code", "passcode", "your code is", "enter the code",
    "confirmation code", "2fa", "two-factor", "two factor",
    "use this code", "code to log", "log in to", "magic link",
    "login link", "use this link to", "temporary code", "access code",
    "use code", "enter code", "your login", "single use",
]


# ── Pure parsing helpers (no side effects) ───────────────────────────────────

def _decode_header(s):
    if not s:
        return ""
    parts = decode_header(s)
    decoded = []
    for part, enc in parts:
        if isinstance(part, bytes):
            decoded.append(part.decode(enc or "utf-8", errors="replace"))
        else:
            decoded.append(part)
    return " ".join(decoded)


def _extract_text(msg):
    texts = []
    if msg.is_multipart():
        for part in msg.walk():
            ctype = part.get_content_type()
            disp  = str(part.get("Content-Disposition", ""))
            if ctype == "text/plain" and "attachment" not in disp:
                payload = part.get_payload(decode=True)
                charset = part.get_content_charset() or "utf-8"
                texts.append(payload.decode(charset, errors="replace"))
            elif ctype == "text/html" and "attachment" not in disp and not texts:
                payload = part.get_payload(decode=True)
                charset = part.get_content_charset() or "utf-8"
                raw_html = payload.decode(charset, errors="replace")
                texts.append(re.sub(r"<[^>]+>", " ", raw_html))
    else:
        payload = msg.get_payload(decode=True)
        if payload:
            charset = msg.get_content_charset() or "utf-8"
            texts.append(payload.decode(charset, errors="replace"))
    return "\n".join(texts)


def _is_otp_email(subject, body):
    combined = (subject + " " + body).lower()
    return any(kw in combined for kw in _OTP_KEYWORDS)


def _extract_code(text):
    upper = text.upper()
    for pattern in _OTP_PATTERNS:
        m = re.search(pattern, upper, re.IGNORECASE | re.MULTILINE)
        if m:
            code = m.group(1).strip()
            if re.match(r'^[A-Z]+$', code) and len(code) <= 4:
                continue
            return code
    return None


def _sender_name(from_header):
    from_header = _decode_header(from_header)
    m = re.match(r'"?([^"<]+)"?\s*<', from_header)
    if m:
        name = m.group(1).strip()
        if name:
            return name
    m2 = re.search(r"@([\w.\-]+)", from_header)
    if m2:
        parts = m2.group(1).split(".")
        return parts[-2].capitalize() if len(parts) >= 2 else m2.group(1)
    return from_header.strip() or "Unknown"


# ── OTPService ────────────────────────────────────────────────────────────────

class OTPService:
    """
    Watches one Gmail account for OTP emails via IMAP IDLE.

    Usage:
        def on_otp(code, app_name, subject):
            copy_to_clipboard(code)
            send_notification(...)

        svc = OTPService("me@gmail.com", "app-password", seen_uids, on_otp)
        svc.start()
        # ... later ...
        svc.stop()

    Adheres to SRP: only responsible for email detection and extraction.
    Clipboard, notifications, and persistence are handled by the caller.
    """

    def __init__(self, email_addr, app_password, seen_uids,
                 on_otp, idle_refresh_minutes=20):
        self.email_addr           = email_addr
        self.app_password         = app_password
        self.seen_uids            = seen_uids        # shared set — caller owns it
        self.on_otp               = on_otp           # callback(code, app_name, subject)
        self.idle_refresh_minutes = idle_refresh_minutes

        self._stop  = threading.Event()
        self._thread = None

    # ── Lifecycle ─────────────────────────────────────────────────────────────

    def start(self):
        """Start watching in a background daemon thread."""
        self._stop.clear()
        self._thread = threading.Thread(
            target=self._run,
            name="OTPService:{}".format(self.email_addr),
            daemon=True,
        )
        self._thread.start()

    def stop(self):
        """Signal the watcher to stop. Non-blocking."""
        self._stop.set()

    @property
    def is_alive(self):
        return self._thread is not None and self._thread.is_alive()

    # ── Internal ──────────────────────────────────────────────────────────────

    def _run(self):
        label        = self.email_addr.split("@")[0]
        idle_timeout = self.idle_refresh_minutes * 60

        while not self._stop.is_set():
            try:
                print("[{}] Connecting...".format(label))
                with IMAPClient(IMAP_HOST, ssl=True) as server:
                    server.login(self.email_addr, self.app_password)
                    server.select_folder("INBOX")
                    print("[{}] Connected.".format(label))

                    self._scan(server, catch_up=True)
                    server.idle()

                    while not self._stop.is_set():
                        responses = server.idle_check(timeout=idle_timeout)
                        if responses:
                            server.idle_done()
                            self._scan(server)
                            server.idle()
                        else:
                            # Refresh IDLE before Gmail's 29-min timeout
                            server.idle_done()
                            server.idle()

            except Exception as exc:
                print("[{}] Error: {}. Reconnecting in 5s...".format(label, exc))
                time.sleep(5)

    def _scan(self, server, catch_up=False):
        """
        Scan INBOX for new OTP emails.
        Uses BODY.PEEK[] so messages are never marked as read.
        catch_up=True also includes today's already-read messages (for reconnect recovery).
        """
        unseen = set(server.search(["UNSEEN"]))
        if catch_up:
            today_str = date.today().strftime("%d-%b-%Y")
            today_all = set(server.search(["SINCE", today_str]))
            candidates = unseen | today_all
        else:
            candidates = unseen

        new_uids = [uid for uid in candidates
                    if str(uid) not in self.seen_uids]

        for uid in new_uids:
            self.seen_uids.add(str(uid))
            try:
                raw_data = server.fetch([uid], ["BODY.PEEK[]"])
                raw = raw_data[uid][b"BODY[]"]
                msg = email.message_from_bytes(raw)

                subject  = _decode_header(msg.get("Subject", ""))
                from_hdr = msg.get("From", "")
                body     = _extract_text(msg)

                if not _is_otp_email(subject, body):
                    continue

                code = _extract_code(subject + "\n" + body)
                if not code:
                    continue

                app_name = _sender_name(from_hdr)
                self.on_otp(code, app_name, subject)

            except Exception as exc:
                print("  Warning: error processing UID {}: {}".format(uid, exc))
