from __future__ import annotations

import email
import imaplib
import re
import ssl
from dataclasses import dataclass
from datetime import datetime, timezone
from email.header import decode_header, make_header
from email.message import Message
from email.utils import getaddresses, parsedate_to_datetime
from pathlib import Path
from typing import Iterable
from uuid import uuid4

from sqlalchemy import or_, select
from sqlalchemy.orm import Session

from app.modules.clients.models import Client
from app.modules.consultants.models import ConsultantProfile
from app.modules.core.iam.models import User
from app.modules.email_integration.models import EmailIncomingAttachment, EmailIncomingMessage, EmailSetting
from app.modules.email_integration.mapping_service import apply_email_mapping

UPLOAD_ROOT = Path("app/ui/static/uploads/incoming_emails")
MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024


@dataclass
class ImapFetchResult:
    fetched: int = 0
    imported: int = 0
    skipped_existing: int = 0
    failed: int = 0
    error: str | None = None


def _decode_mime(value: str | None) -> str:
    if not value:
        return ""
    try:
        return str(make_header(decode_header(value)))
    except Exception:
        return value


def _normalise_email(value: str | None) -> str:
    return (value or "").strip().lower()


def _addresses(header_value: str | None) -> list[tuple[str, str]]:
    decoded = _decode_mime(header_value)
    return [(name, addr.lower()) for name, addr in getaddresses([decoded]) if addr]


def _joined_addresses(header_value: str | None) -> str:
    return ", ".join(addr for _name, addr in _addresses(header_value))


def _received_at(msg: Message) -> datetime | None:
    raw_date = msg.get("Date")
    if not raw_date:
        return None
    try:
        parsed = parsedate_to_datetime(raw_date)
        if parsed.tzinfo is None:
            parsed = parsed.replace(tzinfo=timezone.utc)
        return parsed.astimezone(timezone.utc)
    except Exception:
        return None


def _extract_bodies(msg: Message) -> tuple[str | None, str | None]:
    text_parts: list[str] = []
    html_parts: list[str] = []

    def decode_part(part: Message) -> str:
        payload = part.get_payload(decode=True)
        if payload is None:
            raw = part.get_payload()
            return raw if isinstance(raw, str) else ""
        charset = part.get_content_charset() or "utf-8"
        try:
            return payload.decode(charset, errors="replace")
        except Exception:
            return payload.decode("utf-8", errors="replace")

    if msg.is_multipart():
        for part in msg.walk():
            if part.get_content_maintype() == "multipart":
                continue
            if part.get_filename():
                continue
            ctype = part.get_content_type().lower()
            content = decode_part(part).strip()
            if not content:
                continue
            if ctype == "text/plain":
                text_parts.append(content)
            elif ctype == "text/html":
                html_parts.append(content)
    else:
        ctype = msg.get_content_type().lower()
        content = decode_part(msg).strip()
        if ctype == "text/html":
            html_parts.append(content)
        else:
            text_parts.append(content)

    text = "\n\n".join(text_parts).strip() or None
    html = "\n\n".join(html_parts).strip() or None
    return text, html


def _safe_filename(name: str | None) -> str:
    decoded = _decode_mime(name or "attachment") or "attachment"
    cleaned = re.sub(r"[^A-Za-z0-9._ -]", "_", decoded).strip(" .")
    return cleaned[:180] or "attachment"


def _save_attachments(msg: Message, tenant_id: int | None, branch_id: int | None, incoming_id: int) -> list[EmailIncomingAttachment]:
    saved: list[EmailIncomingAttachment] = []
    base = UPLOAD_ROOT / str(tenant_id or "system") / str(branch_id or "all") / str(incoming_id)
    base.mkdir(parents=True, exist_ok=True)

    for part in msg.walk() if msg.is_multipart() else []:
        if part.get_content_maintype() == "multipart":
            continue
        filename = part.get_filename()
        disposition = (part.get("Content-Disposition") or "").lower()
        if not filename and "attachment" not in disposition:
            continue
        payload = part.get_payload(decode=True) or b""
        if not payload:
            continue
        if len(payload) > MAX_ATTACHMENT_BYTES:
            continue
        safe = _safe_filename(filename)
        target_name = f"{uuid4().hex}_{safe}"
        target = base / target_name
        target.write_bytes(payload)
        saved.append(
            EmailIncomingAttachment(
                incoming_message_id=incoming_id,
                tenant_id=tenant_id,
                branch_id=branch_id,
                filename=safe,
                content_type=part.get_content_type(),
                size_bytes=len(payload),
                storage_path=str(target).replace("\\", "/"),
            )
        )
    return saved


def _match_sender(db: Session, tenant_id: int | None, branch_id: int | None, sender_email: str | None) -> tuple[int | None, int | None, int | None, str]:
    email_value = _normalise_email(sender_email)
    if not email_value:
        return None, None, None, "NEW"

    user_id = None
    client_id = None
    consultant_id = None

    user = db.execute(select(User).where(User.email == email_value).limit(1)).scalar_one_or_none()
    if user:
        user_id = int(user.id)

    if tenant_id:
        client_filters = [Client.tenant_id == tenant_id, or_(Client.email == email_value, Client.alternate_email == email_value)]
        if branch_id:
            client_filters.append(Client.branch_id == branch_id)
        client = db.execute(select(Client).where(*client_filters).limit(1)).scalar_one_or_none()
        if client:
            client_id = int(client.id)

        consultant_filters = [ConsultantProfile.tenant_id == tenant_id, ConsultantProfile.email == email_value]
        if branch_id:
            consultant_filters.append(or_(ConsultantProfile.branch_id == branch_id, ConsultantProfile.branch_id.is_(None)))
        consultant = db.execute(select(ConsultantProfile).where(*consultant_filters).limit(1)).scalar_one_or_none()
        if consultant:
            consultant_id = int(consultant.id)

    status = "MATCHED" if any([user_id, client_id, consultant_id]) else "NEW"
    return user_id, client_id, consultant_id, status


def _connect(setting: EmailSetting):
    host = (setting.imap_host or "").strip()
    port = int(setting.imap_port or 993)
    security = (setting.imap_security or "SSL").upper()
    if not host or not setting.imap_username or not setting.imap_password:
        raise RuntimeError("IMAP settings are incomplete. Please configure IMAP host, username and password.")

    if security == "SSL":
        conn = imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context())
    else:
        conn = imaplib.IMAP4(host, port)
        if security == "STARTTLS":
            conn.starttls(ssl_context=ssl.create_default_context())
    conn.login(setting.imap_username, setting.imap_password)
    return conn


def fetch_incoming_emails(
    db: Session,
    *,
    setting: EmailSetting,
    tenant_id: int | None,
    branch_id: int | None,
    folder: str = "INBOX",
    unread_only: bool = True,
    limit: int = 25,
    mark_seen: bool = False,
) -> ImapFetchResult:
    result = ImapFetchResult()
    mailbox_email = _normalise_email(setting.imap_username or setting.from_email or "mailbox")
    conn = None
    try:
        conn = _connect(setting)
        typ, _ = conn.select(folder, readonly=not mark_seen)
        if typ != "OK":
            raise RuntimeError(f"Unable to open IMAP folder: {folder}")
        criteria = "UNSEEN" if unread_only else "ALL"
        typ, data = conn.search(None, criteria)
        if typ != "OK":
            raise RuntimeError("IMAP search failed.")
        ids = (data[0] or b"").split()
        ids = ids[-max(1, min(int(limit or 25), 100)):]
        result.fetched = len(ids)

        for msg_seq in ids:
            try:
                typ, uid_data = conn.fetch(msg_seq, "(UID)")
                uid_text = uid_data[0].decode(errors="ignore") if uid_data and uid_data[0] else msg_seq.decode()
                uid_match = re.search(r"UID (\d+)", uid_text)
                provider_uid = uid_match.group(1) if uid_match else msg_seq.decode()

                exists = db.execute(
                    select(EmailIncomingMessage).where(
                        EmailIncomingMessage.tenant_id == tenant_id,
                        EmailIncomingMessage.branch_id == branch_id,
                        EmailIncomingMessage.mailbox_email == mailbox_email,
                        EmailIncomingMessage.folder_name == folder,
                        EmailIncomingMessage.provider_uid == provider_uid,
                    )
                ).scalar_one_or_none()
                if exists:
                    result.skipped_existing += 1
                    continue

                typ, msg_data = conn.fetch(msg_seq, "(RFC822)")
                if typ != "OK" or not msg_data:
                    result.failed += 1
                    continue
                raw = None
                for item in msg_data:
                    if isinstance(item, tuple):
                        raw = item[1]
                        break
                if not raw:
                    result.failed += 1
                    continue
                msg = email.message_from_bytes(raw)
                from_rows = _addresses(msg.get("From"))
                sender_name, sender_email = from_rows[0] if from_rows else ("", "")
                body_text, body_html = _extract_bodies(msg)
                user_id, client_id, consultant_id, status = _match_sender(db, tenant_id, branch_id, sender_email)

                incoming = EmailIncomingMessage(
                    tenant_id=tenant_id,
                    branch_id=branch_id,
                    mailbox_email=mailbox_email,
                    folder_name=folder,
                    provider_uid=provider_uid,
                    provider_message_id=_decode_mime(msg.get("Message-ID")) or None,
                    sender_email=sender_email or None,
                    sender_name=sender_name or None,
                    recipient_emails=_joined_addresses(msg.get("To")) or None,
                    cc_emails=_joined_addresses(msg.get("Cc")) or None,
                    subject=_decode_mime(msg.get("Subject"))[:500] or None,
                    body_text=body_text,
                    body_html=body_html,
                    raw_headers="\n".join(f"{k}: {v}" for k, v in msg.items()),
                    received_at_utc=_received_at(msg),
                    status=status,
                    matched_user_id=user_id,
                    matched_client_id=client_id,
                    matched_consultant_id=consultant_id,
                )
                db.add(incoming)
                db.flush()
                attachments = _save_attachments(msg, tenant_id, branch_id, int(incoming.id))
                for attachment in attachments:
                    db.add(attachment)
                incoming.has_attachments = bool(attachments)
                incoming.attachment_count = len(attachments)
                # Phase 7S.3: immediately try tracking-code mapping after fetch.
                # Failure to map should never fail the IMAP import.
                try:
                    apply_email_mapping(db, incoming)
                except Exception as map_exc:
                    incoming.mapping_status = "ERROR"
                    incoming.mapping_notes = str(map_exc)[:1000]
                result.imported += 1
            except Exception:
                result.failed += 1
        if mark_seen:
            for msg_seq in ids:
                try:
                    conn.store(msg_seq, "+FLAGS", "\\Seen")
                except Exception:
                    pass
        return result
    except Exception as exc:
        result.error = str(exc)
        return result
    finally:
        if conn is not None:
            try:
                conn.close()
            except Exception:
                pass
            try:
                conn.logout()
            except Exception:
                pass
