#!/usr/bin/env python3
"""termmail - a small ncurses mail client with a desktop-like layout.

Multiple accounts, folders, message list, reading pane, HTML-to-text,
attachments, compose/reply/forward through IMAP and SMTP.
"""

import curses
import email
import email.utils
import imaplib
import json
import mimetypes
import os
import re
import smtplib
import socket
import ssl
import subprocess
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
import sys
import tempfile
import time

VERSION = "0.2"
CONFIG_DIR = os.path.expanduser("~/.config/termmail")
CONFIG = os.path.join(CONFIG_DIR, "config.json")
DOWNLOADS = os.path.expanduser("~/Downloads")


def load_config():
    try:
        with open(CONFIG, encoding="utf-8") as fh:
            data = json.load(fh)
    except Exception:
        return None
    if isinstance(data, dict) and "accounts" in data:
        return data
    if isinstance(data, dict) and data.get("email"):
        return {"accounts": [data], "active": 0}
    return None


def save_config(cfg):
    os.makedirs(CONFIG_DIR, mode=0o700, exist_ok=True)
    fd = os.open(CONFIG, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w", encoding="utf-8") as fh:
        json.dump(cfg, fh, indent=1)
    os.chmod(CONFIG, 0o600)


def decode_header(value):
    if not value:
        return ""
    try:
        parts = email.header.decode_header(value)
    except Exception:
        return str(value)
    out = []
    for text, enc in parts:
        if isinstance(text, bytes):
            out.append(text.decode(enc or "utf-8", "replace"))
        else:
            out.append(text)
    return "".join(out)


def html_to_text(data, charset, width):
    try:
        proc = subprocess.run(
            ["lynx", "-stdin", "-dump", "-width=%d" % max(40, width - 2),
             "-assume_charset=utf-8", "-display_charset=utf-8", "-nolist"],
            input=data, capture_output=True, timeout=20,
        )
        out = proc.stdout.decode("utf-8", "replace")
        if out.strip():
            return out
    except Exception:
        pass
    try:
        text = data.decode(charset or "utf-8", "replace")
    except Exception:
        text = data.decode("utf-8", "replace")
    text = re.sub(r"(?is)<(script|style).*?</\1>", "", text)
    text = re.sub(r"(?i)<br\s*/?>", "\n", text)
    text = re.sub(r"(?i)</p>", "\n\n", text)
    text = re.sub(r"<[^>]+>", "", text)
    return text


def extract(msg, width):
    """Return (body_text, attachments) where attachments are (name, size, part)."""
    attachments = []
    plain = None
    html = None
    for part in msg.walk():
        if part.is_multipart():
            continue
        disp = str(part.get("Content-Disposition") or "")
        filename = part.get_filename()
        payload = part.get_payload(decode=True)
        if filename and payload is not None:
            attachments.append((decode_header(filename), len(payload), part))
            continue
        ctype = part.get_content_type()
        if ctype == "text/plain" and plain is None:
            plain = (payload, part.get_content_charset())
        elif ctype == "text/html" and html is None:
            html = (payload, part.get_content_charset())
    text = ""
    if plain and plain[0] is not None:
        text = plain[0].decode(plain[1] or "utf-8", "replace")
    elif html and html[0] is not None:
        text = html_to_text(html[0], html[1], width)
    elif msg.get_payload(decode=True) is not None:
        text = msg.get_payload(decode=True).decode(msg.get_content_charset() or "utf-8", "replace")
    elif isinstance(msg.get_payload(), str):
        text = msg.get_payload()
    return text, attachments


def human_size(n):
    n = float(n)
    for unit in ("B", "KB", "MB", "GB"):
        if n < 1024 or unit == "GB":
            return ("%d %s" % (n, unit)) if unit == "B" else ("%.1f %s" % (n, unit))
        n /= 1024


class Mail:
    def __init__(self, cfg):
        self.cfg = cfg
        self.imap = None
        self.error = ""

    def connect(self):
        cfg = self.cfg
        try:
            if cfg.get("ssl", True):
                self.imap = imaplib.IMAP4_SSL(cfg["imap_host"], int(cfg.get("imap_port", 993)))
            else:
                self.imap = imaplib.IMAP4(cfg["imap_host"], int(cfg.get("imap_port", 143)))
                if cfg.get("starttls", True):
                    self.imap.starttls(ssl.create_default_context())
            self.imap.login(cfg["user"], cfg["password"])
            self.error = ""
            return True
        except Exception as exc:  # noqa: BLE001
            self.error = str(exc)
            self.imap = None
            return False

    def folders(self):
        out = []
        if not self.imap:
            return out
        try:
            typ, data = self.imap.list()
            for line in data or []:
                text = line.decode("utf-8", "replace") if isinstance(line, bytes) else str(line)
                m = re.search(r'"([^"]*)"\s*$', text)
                name = m.group(1) if m else text.split()[-1].strip('"')
                if name and name not in (".", "..") and "\\Noselect" not in text:
                    out.append(name)
        except Exception as exc:  # noqa: BLE001
            self.error = str(exc)
        return out

    def select(self, folder):
        if not self.imap:
            return False
        try:
            typ, _ = self.imap.select('"%s"' % folder, readonly=False)
            return typ == "OK"
        except Exception as exc:  # noqa: BLE001
            self.error = str(exc)
            return False

    def messages(self, limit=80):
        out = []
        if not self.imap:
            return out
        try:
            typ, data = self.imap.search(None, "ALL")
            ids = (data[0] or b"").split()[-limit:]
            for mid in reversed(ids):
                typ, chunk = self.imap.fetch(mid, "(FLAGS BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])")
                if typ != "OK" or not chunk or not isinstance(chunk[0], tuple):
                    continue
                msg = email.message_from_bytes(chunk[0][1])
                out.append({
                    "id": mid,
                    "from": decode_header(msg.get("From", "")),
                    "subject": decode_header(msg.get("Subject", "(no subject)")),
                    "date": msg.get("Date", ""),
                    "seen": b"\\Seen" in (chunk[0][0] or b""),
                })
        except Exception as exc:  # noqa: BLE001
            self.error = str(exc)
        return out

    def fetch(self, mid):
        if not self.imap:
            return None
        try:
            typ, chunk = self.imap.fetch(mid, "(RFC822)")
            if typ == "OK" and chunk and isinstance(chunk[0], tuple):
                return email.message_from_bytes(chunk[0][1])
        except Exception as exc:  # noqa: BLE001
            self.error = str(exc)
        return None

    def mark_seen(self, mid):
        try:
            self.imap.store(mid, "+FLAGS", "\\Seen")
        except Exception:
            pass

    def delete(self, mid):
        try:
            self.imap.store(mid, "+FLAGS", "\\Deleted")
            self.imap.expunge()
            return True
        except Exception as exc:  # noqa: BLE001
            self.error = str(exc)
            return False

    def send(self, to, subject, body, files=None):
        cfg = self.cfg
        msg = email.message.EmailMessage()
        msg["From"] = cfg["email"]
        msg["To"] = to
        msg["Subject"] = subject
        msg["Date"] = email.utils.formatdate(localtime=True)
        msg.set_content(body)
        for path in files or []:
            path = os.path.expanduser(path)
            if not os.path.isfile(path):
                continue
            ctype, _ = mimetypes.guess_type(path)
            maintype, _, subtype = (ctype or "application/octet-stream").partition("/")
            with open(path, "rb") as fh:
                msg.add_attachment(fh.read(), maintype=maintype, subtype=subtype,
                                   filename=os.path.basename(path))
        try:
            try:
                if cfg.get("smtp_ssl", False):
                    smtp = smtplib.SMTP_SSL(cfg["smtp_host"], int(cfg.get("smtp_port", 465)), timeout=20)
                else:
                    smtp = smtplib.SMTP(cfg["smtp_host"], int(cfg.get("smtp_port", 587)), timeout=20)
                    if cfg.get("starttls", True):
                        smtp.starttls(context=ssl.create_default_context())
            except ssl.SSLCertVerificationError:
                # Certificate does not match the STARTTLS host; try implicit TLS on 465.
                smtp = smtplib.SMTP_SSL(cfg["smtp_host"], 465, timeout=20)
            smtp.login(cfg["user"], cfg["password"])
            smtp.send_message(msg)
            smtp.quit()
            return True, ""
        except Exception as exc:  # noqa: BLE001
            return False, str(exc)


class App:
    def __init__(self, stdscr, cfg):
        self.scr = stdscr
        self.cfg = cfg
        self.accounts = cfg["accounts"]
        self.active = int(cfg.get("active", 0))
        self.mails = [None] * len(self.accounts)
        self.color = curses.has_colors()
        self.folders = ["INBOX"]
        self.folder = "INBOX"
        self.msgs = []
        self.msel = 0
        self.mprev = ""
        self.attachments = []
        self.current = None
        self.focus = "list"
        self.button = 0
        self.message = ""
        self.buttons = [
            ("Inbox", "inbox"), ("Compose", "compose"), ("Reply", "reply"),
            ("Forward", "forward"), ("Delete", "delete"), ("Attach", "attach"),
            ("Account", "account"), ("Folders", "folders"), ("Search", "search"),
            ("Refresh", "refresh"), ("Quit", "quit"),
        ]
        self.button_xs = []

    # ---------- helpers ----------
    def size(self):
        return self.scr.getmaxyx()

    def account(self):
        return self.accounts[self.active]

    def mail(self):
        m = self.mails[self.active]
        if m is None:
            m = Mail(self.account())
            self.mails[self.active] = m
        return m

    # ---------- drawing ----------
    def draw_bar(self, y, text, focus=False, right=""):
        _, w = self.size()
        attr = (curses.color_pair(1) | (curses.A_BOLD if focus else 0)) if self.color else curses.A_REVERSE
        avail = max(0, w - 1 - (len(right) + 1 if right else 0))
        self.scr.addnstr(y, 0, text[:avail].ljust(avail), avail, attr)
        if right:
            self.scr.addnstr(y, w - 1 - len(right), right, len(right), attr | curses.A_BOLD)

    def draw_buttons(self):
        _, w = self.size()
        x = 0
        self.button_xs = []
        base = curses.color_pair(2) if self.color else curses.A_DIM
        self.scr.addnstr(1, 0, " " * (w - 1), w - 1, base)
        for i, (label, _) in enumerate(self.buttons):
            if x >= w - 2:
                break
            self.button_xs.append(x)
            chunk = label + " "
            attr = (curses.color_pair(3) if self.color else curses.A_REVERSE) if (i == self.button and self.focus == "buttons") else base
            try:
                self.scr.addnstr(1, x, chunk, max(0, w - 1 - x), attr)
            except curses.error:
                pass
            x += len(chunk)

    def draw_folders(self, top, height):
        w = 20
        self.scr.addnstr(top, 0, " Folders".ljust(w - 1), w - 1, curses.A_BOLD)
        for i, name in enumerate(self.folders[: height - 1]):
            sel = name == self.folder
            a = (curses.color_pair(3) if self.color else curses.A_REVERSE) if sel else curses.A_NORMAL
            self.scr.addnstr(top + 1 + i, 0, (" %s" % name)[: w - 1].ljust(w - 1), w - 1, a)

    def draw_list(self, top, height, x, width):
        self.scr.addnstr(top, x, " Messages".ljust(width - 1), width - 1, curses.A_BOLD)
        for i, m in enumerate(self.msgs[: height - 1]):
            who = m["from"].split("<")[0].strip('" ')[:18]
            subj = m["subject"][: max(0, width - 30)]
            flag = " " if m["seen"] else "*"
            line = "%s %-18s %s" % (flag, who, subj)
            sel = i == self.msel
            a = (curses.color_pair(3) if self.color else curses.A_REVERSE) if sel else curses.A_NORMAL
            if not m["seen"] and not sel:
                a |= curses.A_BOLD
            self.scr.addnstr(top + 1 + i, x, line[: width - 1].ljust(width - 1), width - 1, a)

    def draw_preview(self, top, height, x, width):
        self.scr.addnstr(top, x, " Message".ljust(width - 1), width - 1, curses.A_BOLD)
        lines = self.mprev.splitlines() or [""]
        for i in range(height - 1):
            if i >= len(lines):
                break
            self.scr.addnstr(top + 1 + i, x, lines[i][: width - 1], width - 1)

    def redraw(self):
        h, w = self.size()
        self.scr.erase()
        unread = sum(1 for m in self.msgs if not m["seen"])
        right = " %s  %d unread " % (self.folder, unread)
        self.draw_bar(0, " %s  -  termmail" % self.account().get("email", ""), True, right)
        self.draw_buttons()
        top, height, left_w = 2, h - 3, 20
        self.draw_folders(top, height)
        list_h = max(4, height // 2)
        self.draw_list(top, list_h, left_w + 1, w - left_w - 2)
        self.draw_preview(top + list_h, height - list_h, left_w + 1, w - left_w - 2)
        attr = curses.color_pair(1) if self.color else curses.A_REVERSE
        status = self.message or "Tab: focus | Enter: open | c: compose | r: reply | a: attachments | A: account | q: quit"
        self.scr.addnstr(h - 1, 0, (" " + status)[: w - 1].ljust(w - 1), w - 1, attr)
        self.message = ""
        self.scr.refresh()

    # ---------- dialogs ----------
    def input_box(self, title, initial=""):
        h, w = self.size()
        win_w = min(w - 4, 70)
        win = curses.newwin(5, win_w, max(1, h // 2 - 2), max(1, (w - win_w) // 2))
        win.keypad(True)
        text = list(initial)
        pos = len(text)
        first = True
        while True:
            win.erase(); win.box()
            win.addnstr(0, 2, " %s " % title, win_w - 4, curses.A_BOLD)
            win.addnstr(2, 2, "".join(text)[-(win_w - 4):], win_w - 4)
            win.addnstr(3, 2, "Enter: ok   Esc: cancel", win_w - 4, curses.A_DIM)
            win.move(2, 2 + min(pos, win_w - 5))
            win.refresh()
            key = win.getch()
            if key in (10, 13, curses.KEY_ENTER):
                return "".join(text)
            if key == 27:
                return None
            if key in (curses.KEY_BACKSPACE, 127, 8):
                if first:
                    text = []; first = False
                elif text:
                    text.pop()
                pos = len(text)
            elif key == 21:
                text = []; first = False; pos = 0
            elif 32 <= key < 127:
                if first:
                    text = []; first = False
                text.append(chr(key)); pos = len(text)

    def pick(self, title, entries):
        if not entries:
            self.message = "Nothing to choose"
            return None
        h, w = self.size()
        win_h = min(len(entries) + 3, h - 5)
        win_w = min(w - 4, 74)
        win = curses.newwin(win_h, win_w, max(2, (h - win_h) // 2), max(1, (w - win_w) // 2))
        win.keypad(True)
        sel, off = 0, 0
        while True:
            win.erase(); win.box()
            win.addnstr(0, 2, " %s " % title, win_w - 4, curses.A_BOLD)
            visible = max(1, win_h - 3)
            if sel < off:
                off = sel
            if sel >= off + visible:
                off = sel - visible + 1
            for i in range(visible):
                idx = off + i
                if idx >= len(entries):
                    break
                attr = (curses.color_pair(3) if self.color else curses.A_REVERSE) if idx == sel else curses.A_NORMAL
                win.addnstr(1 + i, 1, entries[idx][0].ljust(win_w - 2), win_w - 2, attr)
            win.addnstr(win_h - 2, 1, " Enter: choose   Esc: cancel ", win_w - 2, curses.A_DIM)
            win.refresh()
            key = win.getch()
            if key == curses.KEY_MOUSE:
                try:
                    _, mx, my, _, bs = curses.getmouse()
                except curses.error:
                    continue
                by, bx = win.getbegyx()
                rel = my - by
                if 1 <= rel <= visible:
                    idx = off + rel - 1
                    if 0 <= idx < len(entries):
                        if idx == sel:
                            return entries[sel][1]
                        sel = idx
                continue
            if key in (curses.KEY_UP, ord("k")):
                sel = max(0, sel - 1)
            elif key in (curses.KEY_DOWN, ord("j")):
                sel = min(len(entries) - 1, sel + 1)
            elif key in (10, 13, curses.KEY_ENTER):
                return entries[sel][1]
            elif key == 27:
                return None

    def confirm(self, title, question):
        h, w = self.size()
        win_w = min(w - 4, 70)
        win = curses.newwin(5, win_w, max(1, h // 2 - 2), max(1, (w - win_w) // 2))
        win.keypad(True)
        while True:
            win.erase(); win.box()
            win.addnstr(0, 2, " %s " % title, win_w - 4, curses.A_BOLD)
            win.addnstr(2, 2, question[: win_w - 4], win_w - 4)
            win.addnstr(3, 2, " yes ", 5, curses.A_REVERSE)
            win.addnstr(3, 9, " no ", 4, curses.A_REVERSE)
            win.addnstr(3, 15, "(y/n, Esc cancels)", win_w - 17, curses.A_DIM)
            win.refresh()
            key = win.getch()
            if key == curses.KEY_MOUSE:
                try:
                    _, mx, my, _, bs = curses.getmouse()
                except curses.error:
                    continue
                by, bx = win.getbegyx()
                if my - by == 3 and bs & curses.BUTTON1_CLICKED:
                    if 2 <= mx - bx <= 6:
                        return True
                    if 9 <= mx - bx <= 12:
                        return False
                continue
            if key in (ord("y"), ord("Y")):
                return True
            if key in (ord("n"), ord("N"), 27):
                return False

    def editor_body(self, initial=""):
        path = tempfile.mktemp(prefix="termmail-", suffix=".txt")
        with open(path, "w", encoding="utf-8") as fh:
            fh.write(initial)
        editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") or "vi"
        curses.endwin()
        try:
            subprocess.call([editor, path])
        finally:
            self.scr.refresh()
        try:
            with open(path, encoding="utf-8") as fh:
                return fh.read()
        except Exception:
            return initial
        finally:
            try:
                os.unlink(path)
            except Exception:
                pass

    # ---------- actions ----------
    def connect_active(self):
        m = self.mail()
        if not m.imap and not m.connect():
            self.message = "Connect failed: %s" % m.error
            return False
        return True

    def load_folder(self, name):
        self.folder = name
        m = self.mail()
        if m.select(name):
            self.msgs = m.messages()
        else:
            self.msgs = []
            self.message = "Could not open %s: %s" % (name, m.error)
        self.msel = 0
        self.mprev = ""
        self.attachments = []
        self.current = None

    def open_message(self):
        if not self.msgs:
            return
        mid = self.msgs[self.msel]["id"]
        msg = self.mail().fetch(mid)
        if not msg:
            self.message = "Could not fetch message: %s" % self.mail().error
            return
        width = max(40, self.size()[1] - 22)
        text, atts = extract(msg, width)
        self.current = msg
        self.attachments = atts
        head = "From: %s\nDate: %s\nSubject: %s\n" % (
            decode_header(msg.get("From", "")), msg.get("Date", ""),
            decode_header(msg.get("Subject", "")))
        if atts:
            head += "\nAttachments:\n" + "\n".join(
                "  [%d] %s (%s)" % (i + 1, name, human_size(size)) for i, (name, size, _) in enumerate(atts))
        self.mprev = head + "\n" + "-" * 20 + "\n" + text
        self.mail().mark_seen(mid)
        self.msgs[self.msel]["seen"] = True

    def switch_account(self):
        entries = []
        for i, acc in enumerate(self.accounts):
            entries.append(("%s%s  (%s)" % ("* " if i == self.active else "  ", acc.get("email", "?"), acc.get("imap_host", "")), i))
        choice = self.pick("Accounts", entries)
        if choice is None or choice == self.active:
            return
        self.active = choice
        self.cfg["active"] = choice
        save_config(self.cfg)
        self.folders = ["INBOX"]
        self.msgs = []
        self.mprev = ""
        if self.connect_active():
            self.folders = self.mail().folders() or ["INBOX"]
            self.load_folder("INBOX")
        self.message = "Account: %s" % self.account().get("email", "")

    def save_attachment(self):
        if not self.attachments:
            self.message = "No attachments in this message"
            return
        entries = [("[%d] %s (%s)" % (i + 1, name, human_size(size)), i) for i, (name, size, _) in enumerate(self.attachments)]
        idx = self.pick("Attachments", entries)
        if idx is None:
            return
        name, size, part = self.attachments[idx]
        safe = re.sub(r"[/\\]", "_", name) or "attachment"
        os.makedirs(DOWNLOADS, exist_ok=True)
        default = os.path.join(DOWNLOADS, safe)
        path = self.input_box("Save attachment to", default)
        if not path:
            return
        data = part.get_payload(decode=True)
        if data is None:
            self.message = "Nothing to save"
            return
        try:
            with open(os.path.expanduser(path), "wb") as fh:
                fh.write(data)
            self.message = "Saved to %s" % path
        except Exception as exc:  # noqa: BLE001
            self.message = "Save failed: %s" % exc

    def compose_window(self, to="", subject="", body=""):
        h, w = self.size()
        win = curses.newwin(h - 2, w, 1, 0)
        win.keypad(True)
        to_buf = list(to)
        subj_buf = list(subject)
        body_lines = body.split("\n") if body else [""]
        files = []
        focus = "to"
        cur_to = len(to_buf)
        cur_subj = len(subj_buf)
        cur_row = len(body_lines) - 1
        cur_col = len(body_lines[-1])
        sel_to = True
        sel_subj = False
        buttons = ["Send", "Attach", "Cancel"]
        bsel = 0
        warn = ""
        button_xs = []

        def field_width():
            return max(10, w - 13)

        def set_focus(name):
            nonlocal focus, sel_to, sel_subj
            focus = name
            if name == "to":
                sel_to = True
            elif name == "subject":
                sel_subj = True
            else:
                sel_to = sel_subj = False

        while True:
            win.erase(); win.box()
            win.addnstr(0, 2, " Compose ", 12, curses.A_BOLD)
            win.addnstr(1, 2, "To:     ", 9, curses.A_BOLD)
            fw = field_width()
            to_attr = (curses.color_pair(3) if self.color else curses.A_REVERSE) if focus == "to" else curses.A_NORMAL
            win.addnstr(1, 11, " " * fw, fw, to_attr)
            win.addnstr(1, 11, "".join(to_buf)[:fw], fw, to_attr | (curses.A_BOLD if sel_to else 0))
            win.addnstr(2, 2, "Subject:", 9, curses.A_BOLD)
            subj_attr = (curses.color_pair(3) if self.color else curses.A_REVERSE) if focus == "subject" else curses.A_NORMAL
            win.addnstr(2, 11, " " * fw, fw, subj_attr)
            win.addnstr(2, 11, "".join(subj_buf)[:fw], fw, subj_attr | (curses.A_BOLD if sel_subj else 0))
            win.addnstr(3, 1, "-" * (w - 2), w - 2, curses.A_DIM)
            body_top = 4
            body_h = max(3, h - 12)
            for i in range(body_h):
                if i < len(body_lines):
                    attr = (curses.color_pair(3) if self.color else curses.A_REVERSE) if (focus == "body" and i == cur_row) else curses.A_NORMAL
                    win.addnstr(body_top + i, 2, body_lines[i][: w - 4], w - 4, attr)
            att = "Attachments: " + (", ".join(os.path.basename(f) for f in files) if files else "-")
            win.addnstr(h - 7, 2, att[: w - 4], w - 4, curses.A_DIM)
            x = 2
            button_xs = []
            for i, label in enumerate(buttons):
                chunk = " %s " % label
                button_xs.append((x, x + len(chunk)))
                attr = (curses.color_pair(3) if self.color else curses.A_REVERSE) if (focus == "buttons" and i == bsel) else curses.A_NORMAL
                win.addnstr(h - 5, x, chunk, len(chunk), attr)
                x += len(chunk) + 1
            label = {"to": "To", "subject": "Subject", "body": "Body", "buttons": "Buttons"}[focus]
            hint = warn or "Focus: %s   Tab: next   F2: send   Esc: cancel" % label
            win.addnstr(h - 4, 2, hint[: w - 4], w - 4, curses.A_DIM)
            try:
                if focus == "to":
                    win.move(1, 11 + min(cur_to, fw - 1))
                elif focus == "subject":
                    win.move(2, 11 + min(cur_subj, fw - 1))
                elif focus == "body":
                    win.move(body_top + min(cur_row, body_h - 1), 2 + min(cur_col, w - 5))
            except curses.error:
                pass
            win.refresh()
            key = win.getch()

            if key in (19, curses.KEY_F2):
                addr = "".join(to_buf).strip()
                if not addr or "@" not in addr:
                    warn = "Fill in a valid To address first"
                    continue
                if not self.confirm("Send message", "Send to %s?" % addr):
                    continue
                return {"to": addr, "subject": "".join(subj_buf).strip(),
                        "body": "\n".join(body_lines), "files": files}
            if key == 27:
                return None
            if key == curses.KEY_MOUSE:
                try:
                    _, mx, my, _, bstate = curses.getmouse()
                except curses.error:
                    continue
                if bstate & curses.BUTTON1_CLICKED:
                    by, bx = win.getbegyx()
                    rel = my - by
                    if rel == 1:
                        set_focus("to")
                    elif rel == 2:
                        set_focus("subject")
                    elif 4 <= rel < h - 7:
                        set_focus("body")
                        cur_row = min(len(body_lines) - 1, rel - 4)
                        cur_col = min(cur_col, len(body_lines[cur_row]))
                    elif rel == h - 5:
                        for i, (x0, x1) in enumerate(button_xs):
                            if x0 <= mx - bx <= x1:
                                bsel = i
                                focus = "buttons"
                                if i == 0:
                                    addr = "".join(to_buf).strip()
                                    if not addr or "@" not in addr:
                                        warn = "Fill in a valid To address first"
                                    elif self.confirm("Send message", "Send to %s?" % addr):
                                        return {"to": addr, "subject": "".join(subj_buf).strip(),
                                                "body": "\n".join(body_lines), "files": files}
                                elif i == 1:
                                    path = self.attach_prompt(win, h, w)
                                    if path:
                                        files.append(path)
                                else:
                                    return None
                                break
                continue
            if key == 9 or key == curses.KEY_BTAB:
                order = ["to", "subject", "body", "buttons"]
                idx = order.index(focus)
                set_focus(order[(idx + (1 if key == 9 else -1)) % len(order)])
                continue
            if focus == "buttons":
                if key in (curses.KEY_LEFT, ord("h")):
                    bsel = (bsel - 1) % len(buttons)
                elif key in (curses.KEY_RIGHT, ord("l")):
                    bsel = (bsel + 1) % len(buttons)
                elif key in (10, 13, curses.KEY_ENTER):
                    if buttons[bsel] == "Send":
                        addr = "".join(to_buf).strip()
                        if not addr or "@" not in addr:
                            warn = "Fill in a valid To address first"
                            continue
                        if not self.confirm("Send message", "Send to %s?" % addr):
                            continue
                        return {"to": addr, "subject": "".join(subj_buf).strip(),
                                "body": "\n".join(body_lines), "files": files}
                    if buttons[bsel] == "Attach":
                        path = self.attach_prompt(win, h, w)
                        if path:
                            files.append(path)
                    if buttons[bsel] == "Cancel":
                        return None
                continue
            if focus in ("to", "subject"):
                buf = to_buf if focus == "to" else subj_buf
                pos = cur_to if focus == "to" else cur_subj
                sel = sel_to if focus == "to" else sel_subj
                if key in (curses.KEY_BACKSPACE, 127, 8):
                    if sel:
                        buf[:] = []
                        pos = 0
                        sel = False
                    elif pos > 0:
                        buf.pop(pos - 1)
                        pos -= 1
                elif key == curses.KEY_LEFT:
                    pos = max(0, pos - 1); sel = False
                elif key == curses.KEY_RIGHT:
                    pos = min(len(buf), pos + 1); sel = False
                elif key == curses.KEY_HOME:
                    pos = 0; sel = False
                elif key == curses.KEY_END:
                    pos = len(buf); sel = False
                elif key == 1:  # Ctrl-A
                    sel = True
                elif key == 21:
                    buf[:] = []; pos = 0; sel = False
                elif key in (10, 13, curses.KEY_ENTER):
                    set_focus("subject" if focus == "to" else "body")
                elif 32 <= key < 127:
                    if sel:
                        buf[:] = []
                        pos = 0
                        sel = False
                    buf.insert(pos, chr(key))
                    pos += 1
                if focus == "to":
                    cur_to = pos; sel_to = sel
                else:
                    cur_subj = pos; sel_subj = sel
                continue
            if key in (curses.KEY_BACKSPACE, 127, 8):
                if cur_col > 0:
                    line = body_lines[cur_row]
                    body_lines[cur_row] = line[: cur_col - 1] + line[cur_col:]
                    cur_col -= 1
                elif cur_row > 0:
                    prev = body_lines[cur_row - 1]
                    body_lines[cur_row - 1] = prev + body_lines[cur_row]
                    del body_lines[cur_row]
                    cur_row -= 1
                    cur_col = len(prev)
            elif key in (10, 13, curses.KEY_ENTER):
                line = body_lines[cur_row]
                body_lines[cur_row] = line[:cur_col]
                body_lines.insert(cur_row + 1, line[cur_col:])
                cur_row += 1
                cur_col = 0
            elif key == curses.KEY_UP:
                cur_row = max(0, cur_row - 1); cur_col = min(cur_col, len(body_lines[cur_row]))
            elif key == curses.KEY_DOWN:
                cur_row = min(len(body_lines) - 1, cur_row + 1); cur_col = min(cur_col, len(body_lines[cur_row]))
            elif key == curses.KEY_LEFT:
                if cur_col > 0:
                    cur_col -= 1
                elif cur_row > 0:
                    cur_row -= 1; cur_col = len(body_lines[cur_row])
            elif key == curses.KEY_RIGHT:
                if cur_col < len(body_lines[cur_row]):
                    cur_col += 1
                elif cur_row < len(body_lines) - 1:
                    cur_row += 1; cur_col = 0
            elif 32 <= key < 127:
                line = body_lines[cur_row]
                body_lines[cur_row] = line[:cur_col] + chr(key) + line[cur_col:]
                cur_col += 1

    def attach_prompt(self, parent, h, w):
        win_w = min(w - 8, 66)
        win = curses.newwin(5, win_w, max(2, h // 2 - 2), max(2, (w - win_w) // 2))
        win.keypad(True)
        text = []
        pos = 0
        while True:
            win.erase(); win.box()
            win.addnstr(0, 2, " Attach file (path) ", win_w - 4, curses.A_BOLD)
            win.addnstr(2, 2, "".join(text)[-(win_w - 4):], win_w - 4)
            win.addnstr(3, 2, "Enter: add   Esc: cancel", win_w - 4, curses.A_DIM)
            win.move(2, 2 + min(pos, win_w - 5))
            win.refresh()
            key = win.getch()
            if key in (10, 13, curses.KEY_ENTER):
                path = os.path.expanduser("".join(text).strip())
                if path and os.path.isfile(path):
                    return path
                win.addnstr(3, 2, " File not found - try again ", win_w - 4, curses.A_BOLD)
                win.refresh()
                win.getch()
            elif key == 27:
                return None
            elif key in (curses.KEY_BACKSPACE, 127, 8):
                if text:
                    text.pop()
                pos = len(text)
            elif 32 <= key < 127:
                text.append(chr(key)); pos = len(text)

    def compose(self, mode="new"):
        body = ""
        to = subj = ""
        if mode in ("reply", "forward") and self.msgs:
            msg = self.mail().fetch(self.msgs[self.msel]["id"])
            if msg:
                if mode == "reply":
                    to = decode_header(msg.get("From", ""))
                    subj = "Re: " + decode_header(msg.get("Subject", ""))
                    body = "\n\n" + "\n".join("> " + l for l in self.mprev.splitlines()[4:])
                else:
                    subj = "Fwd: " + decode_header(msg.get("Subject", ""))
                    body = "\n\n---------- Forwarded message ----------\n" + self.mprev
        result = self.compose_window(to, subj, body)
        if not result:
            self.message = "Compose cancelled"
            return
        ok, err = self.mail().send(result["to"], result["subject"], result["body"], result["files"])
        self.message = "Sent to %s" % result["to"] if ok else "Send failed: %s" % err

    def delete_message(self):
        if not self.msgs:
            return
        mid = self.msgs[self.msel]["id"]
        if self.mail().delete(mid):
            self.message = "Deleted"
            self.load_folder(self.folder)
        else:
            self.message = "Delete failed: %s" % self.mail().error

    def search(self):
        term = self.input_box("Search (subject/body text)")
        if not term:
            return
        try:
            typ, data = self.mail().imap.search(None, "TEXT", '"%s"' % term)
            ids = (data[0] or b"").split()[-50:]
            self.msgs = []
            for mid in reversed(ids):
                msg = self.mail().fetch(mid)
                if msg:
                    self.msgs.append({
                        "id": mid, "from": decode_header(msg.get("From", "")),
                        "subject": decode_header(msg.get("Subject", "")),
                        "date": msg.get("Date", ""), "seen": True,
                    })
            self.msel = 0
            self.message = "%d result(s)" % len(self.msgs)
        except Exception as exc:  # noqa: BLE001
            self.message = "Search failed: %s" % exc

    def action(self, name):
        if name == "quit":
            raise SystemExit
        if name == "inbox":
            self.load_folder("INBOX")
        elif name == "compose":
            self.compose("new")
        elif name == "reply":
            self.compose("reply")
        elif name == "forward":
            self.compose("forward")
        elif name == "delete":
            self.delete_message()
        elif name == "attach":
            self.save_attachment()
        elif name == "account":
            self.switch_account()
        elif name == "folders":
            self.focus = "folders"
        elif name == "search":
            self.search()
        elif name == "refresh":
            self.load_folder(self.folder)
            self.message = "Refreshed"

    def handle_key(self, key):
        if key == 9:
            self.focus = {"list": "folders", "folders": "buttons", "buttons": "list"}.get(self.focus, "list")
            return
        if self.focus == "buttons":
            if key in (curses.KEY_LEFT, ord("h")):
                self.button = (self.button - 1) % len(self.buttons)
            elif key in (curses.KEY_RIGHT, ord("l")):
                self.button = (self.button + 1) % len(self.buttons)
            elif key in (10, 13, curses.KEY_ENTER):
                self.action(self.buttons[self.button][1])
            return
        if self.focus == "folders":
            if key in (curses.KEY_UP, ord("k")):
                idx = max(0, self.folders.index(self.folder) - 1)
                self.load_folder(self.folders[idx])
            elif key in (curses.KEY_DOWN, ord("j")):
                idx = min(len(self.folders) - 1, self.folders.index(self.folder) + 1)
                self.load_folder(self.folders[idx])
            elif key in (10, 13, curses.KEY_ENTER):
                self.focus = "list"
            return
        if key in (curses.KEY_UP, ord("k")):
            self.msel = max(0, self.msel - 1)
        elif key in (curses.KEY_DOWN, ord("j")):
            self.msel = min(max(0, len(self.msgs) - 1), self.msel + 1)
        elif key in (10, 13, curses.KEY_ENTER, ord("o")):
            self.open_message()
        elif key == ord("c"):
            self.compose("new")
        elif key == ord("r"):
            self.compose("reply")
        elif key == ord("f"):
            self.compose("forward")
        elif key == ord("d"):
            self.delete_message()
        elif key == ord("a"):
            self.save_attachment()
        elif key == ord("A"):
            self.switch_account()
        elif key == ord("/"):
            self.search()
        elif key == ord("R"):
            self.load_folder(self.folder)
            self.message = "Refreshed"
        elif key == ord("?"):
            self.show_help()
        elif key == ord("q"):
            raise SystemExit

    def show_help(self):
        h, w = self.size()
        lines = [
            "Tab            folders -> messages -> buttons",
            "Up/Down        select a folder or message",
            "Enter          open a message",
            "c / r / f      compose / reply / forward",
            "d              delete the selected message",
            "a              attachments: choose and save",
            "A              switch account",
            "/              search",
            "R              refresh",
            "q              quit",
            "",
            "HTML mail is converted to text (lynx).",
            "Compose: Tab between fields; F2 or the Send button sends.",
        ]
        win_h = min(len(lines) + 4, h - 4)
        win_w = min(w - 4, 74)
        win = curses.newwin(win_h, win_w, max(1, (h - win_h) // 2), max(1, (w - win_w) // 2))
        win.keypad(True)
        win.erase(); win.box()
        win.addnstr(0, 2, " Help ", win_w - 4, curses.A_BOLD)
        for i, line in enumerate(lines[: win_h - 2]):
            win.addnstr(1 + i, 2, line[: win_w - 4], win_w - 4)
        win.refresh()
        win.getch()

    def handle_mouse(self, mx, my, bstate):
        h, w = self.size()
        list_h = max(4, (h - 3) // 2)
        if bstate & (curses.BUTTON1_CLICKED | curses.BUTTON1_DOUBLE_CLICKED):
            if my == 1:
                idx = 0
                for i, x in enumerate(self.button_xs):
                    if x <= mx:
                        idx = i
                self.button = idx
                self.focus = "buttons"
                self.action(self.buttons[idx][1])
            elif mx < 20 and 3 <= my < h - 1:
                fi = my - 3
                if fi < len(self.folders):
                    self.load_folder(self.folders[fi])
                    self.focus = "folders"
            elif mx >= 21 and 3 <= my < 3 + list_h:
                mi = my - 3
                if mi < len(self.msgs):
                    if mi == self.msel:
                        self.open_message()
                    else:
                        self.msel = mi
                    self.focus = "list"
        elif bstate & curses.BUTTON4_PRESSED:
            self.msel = max(0, self.msel - 3)
        elif bstate & curses.BUTTON5_PRESSED:
            self.msel = min(max(0, len(self.msgs) - 1), self.msel + 3)

    def run(self):
        self.connect_active()
        if self.mail().imap:
            self.folders = self.mail().folders() or ["INBOX"]
            self.load_folder("INBOX")
        while True:
            self.redraw()
            key = self.scr.getch()
            if key == curses.KEY_RESIZE:
                continue
            if key == curses.KEY_MOUSE:
                try:
                    _, mx, my, _, bstate = curses.getmouse()
                except curses.error:
                    continue
                self.handle_mouse(mx, my, bstate)
                continue
            self.handle_key(key)


def mini_input(stdscr, title, initial="", secret=False):
    h, w = stdscr.getmaxyx()
    win_w = min(w - 4, 70)
    win = curses.newwin(5, win_w, max(1, h // 2 - 2), max(1, (w - win_w) // 2))
    win.keypad(True)
    text = list(initial)
    pos = len(text)
    first = True
    while True:
        win.erase(); win.box()
        win.addnstr(0, 2, " %s " % title, win_w - 4, curses.A_BOLD)
        shown = ("*" * len(text)) if secret else "".join(text)
        win.addnstr(2, 2, shown[-(win_w - 4):], win_w - 4)
        win.addnstr(3, 2, "Enter: ok   Esc: cancel", win_w - 4, curses.A_DIM)
        win.move(2, 2 + min(pos, win_w - 5))
        win.refresh()
        key = win.getch()
        if key in (10, 13, curses.KEY_ENTER):
            return "".join(text)
        if key == 27:
            return None
        if key in (curses.KEY_BACKSPACE, 127, 8):
            if first:
                text = []; first = False
            elif text:
                text.pop()
            pos = len(text)
        elif 32 <= key < 127:
            if first:
                text = []; first = False
            text.append(chr(key)); pos = len(text)


def _dig(name, rrtype):
    try:
        out = subprocess.run(["dig", "+short", name, rrtype], capture_output=True, text=True, timeout=6).stdout
        return [line.strip() for line in out.splitlines() if line.strip()]
    except Exception:
        return []


def _tcp_ok(host, port, timeout=3):
    try:
        sock = socket.create_connection((host, int(port)), timeout)
        sock.close()
        return True
    except Exception:
        return False


def preset_for(domain):
    d = domain.lower()
    def mk(imap, iport, ssl_, smtp, sport, starttls, smtp_ssl):
        return {"imap_host": imap, "imap_port": iport, "ssl": ssl_,
                "smtp_host": smtp, "smtp_port": sport,
                "starttls": starttls, "smtp_ssl": smtp_ssl, "source": "preset"}
    if d in ("gmail.com", "googlemail.com"):
        return mk("imap.gmail.com", 993, True, "smtp.gmail.com", 587, True, False)
    if d in ("outlook.com", "hotmail.com", "live.com", "msn.com", "outlook.de"):
        return mk("outlook.office365.com", 993, True, "smtp.office365.com", 587, True, False)
    if d.endswith("yahoo.com") or d.endswith("ymail.com"):
        return mk("imap.mail.yahoo.com", 993, True, "smtp.mail.yahoo.com", 465, False, True)
    if d.endswith("icloud.com") or d.endswith("me.com") or d.endswith("mac.com"):
        return mk("imap.mail.me.com", 993, True, "smtp.mail.me.com", 587, True, False)
    if d.endswith("fastmail.com") or d.endswith("fastmail.fm"):
        return mk("imap.fastmail.com", 993, True, "smtp.fastmail.com", 465, False, True)
    if d.endswith("gmx.net") or d.endswith("gmx.de") or d.endswith("gmx.com") or d.endswith("gmx.at"):
        return mk("imap.gmx.net", 993, True, "mail.gmx.net", 587, True, False)
    if d.endswith("zoho.com") or d.endswith("zohomail.com"):
        return mk("imap.zoho.com", 993, True, "smtp.zoho.com", 465, False, True)
    if d.endswith("yandex.com") or d.endswith("yandex.ru"):
        return mk("imap.yandex.com", 993, True, "smtp.yandex.com", 465, False, True)
    if d.endswith("proton.me") or d.endswith("protonmail.com"):
        return mk("127.0.0.1", 1143, False, "127.0.0.1", 1025, True, False)
    return None


def _autoconfig(domain, address):
    urls = [
        "https://autoconfig.%s/mail/config-v1.1.xml?emailaddress=%s" % (domain, urllib.parse.quote(address)),
        "https://%s/.well-known/autoconfig/mail/config-v1.1.xml" % domain,
    ]
    for url in urls:
        try:
            req = urllib.request.Request(url, headers={"User-Agent": "termmail/0.2"})
            with urllib.request.urlopen(req, timeout=6) as resp:
                data = resp.read(512 * 1024)
        except Exception:
            continue
        try:
            root = ET.fromstring(data)
        except Exception:
            continue
        cfg = {"source": "autoconfig", "ssl": True, "starttls": True, "smtp_ssl": False}
        for node in root.iter():
            tag = node.tag.split("}")[-1]
            if tag == "incomingServer" and node.get("type", "").upper() == "IMAP":
                cfg["imap_host"] = (node.findtext("hostname") or "").strip()
                cfg["imap_port"] = int((node.findtext("port") or "993").strip())
                sock = (node.findtext("socketType") or "SSL").strip().upper()
                cfg["ssl"] = sock == "SSL"
            elif tag == "outgoingServer" and node.get("type", "").upper() == "SMTP":
                cfg["smtp_host"] = (node.findtext("hostname") or "").strip()
                cfg["smtp_port"] = int((node.findtext("port") or "587").strip())
                sock = (node.findtext("socketType") or "STARTTLS").strip().upper()
                cfg["smtp_ssl"] = sock == "SSL"
                cfg["starttls"] = sock != "SSL"
        if cfg.get("imap_host") and cfg.get("smtp_host"):
            return cfg
    return None


def detect_settings(address):
    domain = address.rsplit("@", 1)[-1].lower()
    cfg = preset_for(domain)
    if cfg:
        return cfg

    srv_imap = _dig("_imaps._tcp.%s" % domain, "SRV")
    srv_smtp = _dig("_submissions._tcp.%s" % domain, "SRV") or _dig("_submission._tcp.%s" % domain, "SRV")
    if srv_imap and srv_smtp:
        def parse_srv(rec):
            parts = rec.split()
            return parts[3].rstrip("."), int(parts[2])
        ih, ip = parse_srv(srv_imap[0])
        sh, sp = parse_srv(srv_smtp[0])
        return {"imap_host": ih, "imap_port": ip, "ssl": True,
                "smtp_host": sh, "smtp_port": sp, "starttls": sp != 465,
                "smtp_ssl": sp == 465, "source": "SRV"}

    cfg = _autoconfig(domain, address)
    if cfg:
        return cfg

    mx = " ".join(_dig(domain, "MX")).lower()
    if "google" in mx:
        cfg = preset_for("gmail.com")
    elif "outlook" in mx or "microsoft" in mx:
        cfg = preset_for("outlook.com")
    elif "yahoodns" in mx or "yahoo" in mx:
        cfg = preset_for("yahoo.com")
    elif "icloud" in mx or "apple" in mx:
        cfg = preset_for("icloud.com")
    elif "messagingengine" in mx:
        cfg = preset_for("fastmail.com")
    if cfg:
        cfg["source"] = "MX"
        return cfg

    for host in ("imap.%s" % domain, "mail.%s" % domain):
        if _tcp_ok(host, 993):
            for smtp in ("smtp.%s" % domain, "mail.%s" % domain):
                if _tcp_ok(smtp, 587):
                    return {"imap_host": host, "imap_port": 993, "ssl": True,
                            "smtp_host": smtp, "smtp_port": 587, "starttls": True,
                            "smtp_ssl": False, "source": "guess"}
    return {"source": "none"}


def setup_wizard(stdscr):
    accounts = []
    while True:
        email = mini_input(stdscr, "Email address", "")
        if email is None:
            return None
        h, w = stdscr.getmaxyx()
        splash = curses.newwin(3, min(46, w - 4), max(1, h // 2 - 1), max(1, (w - min(46, w - 4)) // 2))
        splash.box()
        splash.addnstr(1, 2, " Detecting mail settings... ", min(44, w - 8), curses.A_BOLD)
        splash.refresh()
        det = detect_settings(email)
        splash.clear()
        del splash
        stdscr.refresh()
        source = det.get("source", "none")

        fields = [
            ("imap_host", "IMAP server (%s)" % source, det.get("imap_host", "")),
            ("imap_port", "IMAP port", str(det.get("imap_port", 993))),
            ("smtp_host", "SMTP server (%s)" % source, det.get("smtp_host", "")),
            ("smtp_port", "SMTP port", str(det.get("smtp_port", 587))),
            ("user", "Username", email),
            ("password", "Password (Gmail: 16-char app password)", ""),
        ]
        cfg = {"ssl": det.get("ssl", True), "starttls": det.get("starttls", True),
               "smtp_ssl": det.get("smtp_ssl", False), "email": email}
        for key, label, default in fields:
            val = mini_input(stdscr, label, default, secret=(key == "password"))
            if val is None:
                return None
            cfg[key] = val
        if not cfg.get("user"):
            cfg["user"] = cfg["email"]
        accounts.append(cfg)
        # ask for another account
        h, w = stdscr.getmaxyx()
        win_w = min(w - 4, 60)
        win = curses.newwin(4, win_w, max(1, h // 2 - 2), max(1, (w - win_w) // 2))
        win.keypad(True)
        win.erase(); win.box()
        win.addnstr(0, 2, " Another account? ", win_w - 4, curses.A_BOLD)
        win.addnstr(2, 2, "y: add another   n: done", win_w - 4)
        win.refresh()
        while True:
            key = win.getch()
            if key in (ord("y"), ord("Y")):
                break
            if key in (ord("n"), ord("N"), 27):
                data = {"accounts": accounts, "active": 0}
                save_config(data)
                return data


def main(stdscr):
    curses.curs_set(0)
    stdscr.keypad(True)
    try:
        curses.mousemask(curses.ALL_MOUSE_EVENTS)
    except curses.error:
        pass
    try:
        curses.start_color(); curses.use_default_colors()
        curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE)
        curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_CYAN)
        curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_YELLOW)
    except curses.error:
        pass
    cfg = load_config()
    if not cfg:
        cfg = setup_wizard(stdscr)
        if not cfg:
            return
    App(stdscr, cfg).run()


if __name__ == "__main__":
    if "--version" in sys.argv:
        print("termmail %s" % VERSION); raise SystemExit(0)
    if "-h" in sys.argv or "--help" in sys.argv:
        print("usage: termmail"); print("A small ncurses mail client (IMAP/SMTP)."); raise SystemExit(0)
    if os.environ.get("TERM", "") in ("", "dumb", "unknown"):
        os.environ["TERM"] = "xterm-256color"
    try:
        curses.wrapper(main)
    except Exception:
        import traceback
        with open("/tmp/termmail.err", "w", encoding="utf-8") as fh:
            traceback.print_exc(file=fh)
        raise
