#!/usr/bin/env python3
import email
import imaplib
import json
import logging
import os
import re
from datetime import datetime, timedelta, timezone

import redis
from celery import shared_task

from app.config import settings
from app.tasks.convert_to_pdf import convert_to_pdf  # new conversion task
from app.tasks.process_document import process_document  # Updated import
from app.utils.allowed_types import (
    ALL_CATEGORIES,
    DEFAULT_CATEGORIES,
    get_allowed_types_for_categories,
)

# Database session for per-user IMAP accounts (imported lazily to avoid circular imports)
_db_session_factory = None

# Maximum length to store as last_error to prevent DB bloat
_MAX_ERROR_LENGTH = 500


def _get_db_session():
    """Return a new SQLAlchemy session (lazy import to avoid startup issues)."""
    global _db_session_factory  # noqa: PLW0603
    if _db_session_factory is None:
        from app.database import SessionLocal

        _db_session_factory = SessionLocal
    return _db_session_factory()


logger = logging.getLogger(__name__)

# Initialize Redis connection using Celery's Redis settings
redis_client = redis.StrictRedis.from_url(settings.redis_url, decode_responses=True)


def _decrypt_imap_password(password: str | None) -> str | None:
    """Decrypt an IMAP account password stored in the database.

    Passwords are stored encrypted (Fernet, ``enc:`` prefix) for new records;
    legacy plaintext records are returned unchanged so existing accounts
    continue to work until they are next updated via the API.
    """
    from app.utils.encryption import decrypt_value

    return decrypt_value(password)


def _resolve_categories_for_profile(profile_id: int | None) -> list[str]:
    """Return the list of allowed categories for a profile ID.

    Loads the profile from the database.  If ``profile_id`` is ``None`` or the
    profile is not found, falls back to the global ``settings.imap_attachment_filter``
    string (``'documents_only'`` → default categories; ``'all'`` → all categories).
    """
    if profile_id is not None:
        try:
            from app.models import ImapIngestionProfile

            db = _get_db_session()
            try:
                profile = db.query(ImapIngestionProfile).filter(ImapIngestionProfile.id == profile_id).first()
                if profile:
                    return json.loads(profile.allowed_categories)
            finally:
                db.close()
        except Exception as exc:  # noqa: BLE001
            logger.warning(
                "Could not load IMAP ingestion profile %d (%s: %s) — using global default",
                profile_id,
                type(exc).__name__,
                exc,
            )

    # Fall back to global setting
    if settings.imap_attachment_filter == "all":
        return ALL_CATEGORIES
    return DEFAULT_CATEGORIES


LOCK_KEY = "imap_lock"  # Unique key for locking
LOCK_EXPIRE = 300  # Lock expires in 5 minutes

# Local cache file for tracking processed emails
CACHE_FILE = os.path.join(settings.workdir, "processed_mails.json")


def acquire_lock():
    """Attempt to acquire a Redis-based lock. If acquired, set an expiration."""
    lock_acquired = redis_client.setnx(LOCK_KEY, "locked")
    if lock_acquired:
        redis_client.expire(LOCK_KEY, LOCK_EXPIRE)
        logger.info("Lock acquired for IMAP processing.")
        return True
    logger.warning("Lock already held. Skipping this cycle.")
    return False


def release_lock():
    """Release the lock by deleting the Redis key."""
    redis_client.delete(LOCK_KEY)
    logger.info("Lock released.")


def load_processed_emails():
    """Load the list of already processed emails from a local JSON file."""
    if os.path.exists(CACHE_FILE):
        try:
            with open(CACHE_FILE, "r") as f:
                processed_emails = json.load(f)
                processed_emails = cleanup_old_entries(processed_emails)
                return processed_emails
        except json.JSONDecodeError:
            logger.warning("Failed to decode JSON, resetting processed emails cache.")
            return {}
    return {}


def save_processed_emails(processed_emails):
    """Save the processed email IDs to a local JSON file."""
    with open(CACHE_FILE, "w") as f:
        json.dump(processed_emails, f, indent=4)


def cleanup_old_entries(processed_emails):
    """Remove entries older than 7 days from the cache to avoid infinite growth."""
    seven_days_ago = datetime.now(timezone.utc) - timedelta(days=7)
    valid_emails = {}
    for msg_id, date_str in processed_emails.items():
        naive_dt = datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S")
        aware_dt = naive_dt.replace(tzinfo=timezone.utc)
        if aware_dt > seven_days_ago:
            valid_emails[msg_id] = date_str
    return valid_emails


@shared_task
def pull_all_inboxes():
    """
    Periodic Celery task that checks all configured IMAP mailboxes
    and fetches attachments from new emails.
    Ensures only one instance runs at a time using Redis-based locking.

    Processes:
    1. System-level mailboxes configured via environment variables (IMAP1, IMAP2).
    2. Per-user IMAP accounts stored in the ``user_imap_accounts`` database table.
    """
    if not acquire_lock():
        logger.info("Skipping execution: Another instance is running.")
        return

    try:
        logger.info("Starting pull_all_inboxes")

        # Mailbox #1 (non-Gmail)
        check_and_pull_mailbox(
            mailbox_key="imap1",
            host=settings.imap1_host,
            port=settings.imap1_port,
            username=settings.imap1_username,
            password=settings.imap1_password,
            use_ssl=settings.imap1_ssl,
            delete_after_process=settings.imap1_delete_after_process,
        )

        # Mailbox #2 (Gmail)
        check_and_pull_mailbox(
            mailbox_key="imap2",
            host=settings.imap2_host,
            port=settings.imap2_port,
            username=settings.imap2_username,
            password=settings.imap2_password,
            use_ssl=settings.imap2_ssl,
            delete_after_process=settings.imap2_delete_after_process,
        )

        # Per-user IMAP accounts from the database
        _pull_user_imap_accounts()

        # Per-user IMAP integrations from the UserIntegration model
        _pull_user_integration_imap()

        logger.info("Finished pull_all_inboxes")

    finally:
        release_lock()


def _pull_user_imap_accounts() -> None:
    """Iterate over all active per-user IMAP accounts and pull their inboxes."""
    try:
        from app.models import UserImapAccount

        db = _get_db_session()
        try:
            accounts = db.query(UserImapAccount).filter(UserImapAccount.is_active.is_(True)).all()
            logger.info("Processing %d per-user IMAP account(s)", len(accounts))
            for acct in accounts:
                # Use a descriptive identifier for logging and processed-email cache keys
                account_identifier = f"user_{acct.owner_id}_{acct.id}"
                try:
                    pull_inbox(
                        mailbox_key=account_identifier,
                        host=acct.host,
                        port=acct.port,
                        username=acct.username,
                        password=_decrypt_imap_password(acct.password),
                        use_ssl=acct.use_ssl,
                        delete_after_process=acct.delete_after_process,
                        owner_id=acct.owner_id,
                        allowed_categories=_resolve_categories_for_profile(acct.profile_id),
                    )
                    # Record successful poll
                    acct.last_checked_at = datetime.now(timezone.utc)
                    acct.last_error = None
                    db.commit()
                except Exception as exc:  # noqa: BLE001
                    error_msg = str(exc)[:_MAX_ERROR_LENGTH]
                    logger.error(
                        "Error pulling user IMAP account %d (%s@%s): %s",
                        acct.id,
                        acct.username,
                        acct.host,
                        error_msg,
                    )
                    try:
                        acct.last_checked_at = datetime.now(timezone.utc)
                        acct.last_error = error_msg
                        db.commit()
                    except Exception:  # noqa: BLE001
                        db.rollback()
        finally:
            db.close()
    except Exception as exc:  # noqa: BLE001
        logger.error("Failed to process per-user IMAP accounts: %s", exc)


def _pull_user_integration_imap() -> None:
    """Iterate over all active IMAP UserIntegrations and pull their inboxes.

    This polls the ``user_integrations`` table for records with
    ``integration_type='IMAP'``, ``direction='SOURCE'``, and ``is_active=True``.
    Each integration's config/credentials are decoded and passed to
    :func:`pull_inbox` with the owning user's ``owner_id`` so that ingested
    documents are correctly attributed.

    Individual connection failures are caught and recorded on the integration
    without crashing the polling loop.
    """
    try:
        import json as _json

        from app.models import IntegrationDirection, IntegrationType, UserIntegration
        from app.utils.encryption import decrypt_value

        db = _get_db_session()
        try:
            integrations = (
                db.query(UserIntegration)
                .filter(
                    UserIntegration.integration_type == IntegrationType.IMAP,
                    UserIntegration.direction == IntegrationDirection.SOURCE,
                    UserIntegration.is_active.is_(True),
                )
                .all()
            )
            logger.info("Processing %d IMAP UserIntegration(s)", len(integrations))
            for integ in integrations:
                account_identifier = f"integration_{integ.owner_id}_{integ.id}"
                try:
                    cfg = _json.loads(integ.config) if integ.config else {}
                    raw_creds = decrypt_value(integ.credentials) if integ.credentials else None
                    creds = _json.loads(raw_creds) if raw_creds else {}

                    host = cfg.get("host")
                    port = int(cfg.get("port", 993))
                    username = cfg.get("username")
                    password = creds.get("password")
                    use_ssl = cfg.get("use_ssl", True)
                    delete_after = cfg.get("delete_after_process", False)
                    gmail_labels = cfg.get("gmail_apply_labels", True)
                    # Integrations can store a profile_id in config; fall back to global default
                    profile_id = cfg.get("profile_id")
                    allowed_categories = _resolve_categories_for_profile(profile_id)

                    if not (host and username and password):
                        logger.warning(
                            "IMAP integration %d (owner %s) has incomplete config — skipping.",
                            integ.id,
                            integ.owner_id,
                        )
                        continue

                    pull_inbox(
                        mailbox_key=account_identifier,
                        host=host,
                        port=port,
                        username=username,
                        password=password,
                        use_ssl=use_ssl,
                        delete_after_process=delete_after,
                        owner_id=integ.owner_id,
                        gmail_apply_labels=gmail_labels,
                        allowed_categories=allowed_categories,
                    )
                    integ.last_used_at = datetime.now(timezone.utc)
                    integ.last_error = None
                    db.commit()
                except Exception as exc:  # noqa: BLE001
                    error_msg = str(exc)[:_MAX_ERROR_LENGTH]
                    logger.error(
                        "Error pulling IMAP integration %d (owner %s): %s",
                        integ.id,
                        integ.owner_id,
                        error_msg,
                    )
                    try:
                        integ.last_used_at = datetime.now(timezone.utc)
                        integ.last_error = error_msg
                        db.commit()
                    except Exception:  # noqa: BLE001
                        db.rollback()
        finally:
            db.close()
    except Exception as exc:  # noqa: BLE001
        logger.error("Failed to process IMAP UserIntegrations: %s", exc)


def check_and_pull_mailbox(
    mailbox_key: str,
    host: str | None,
    port: int | None,
    username: str | None,
    password: str | None,
    use_ssl: bool,
    delete_after_process: bool,
):
    """Validates config and invokes pulling from the mailbox if valid."""
    if not (host and port and username and password):
        logger.warning(f"Mailbox {mailbox_key} is missing config, skipping.")
        return

    logger.info(f"Checking mailbox: {mailbox_key}")
    pull_inbox(
        mailbox_key=mailbox_key,
        host=host,
        port=port,
        username=username,
        password=password,
        use_ssl=use_ssl,
        delete_after_process=delete_after_process,
    )


def pull_inbox(
    mailbox_key,
    host,
    port,
    username,
    password,
    use_ssl,
    delete_after_process,
    owner_id=None,
    gmail_apply_labels=True,
    allowed_categories=None,
):
    """
    Connects to the IMAP inbox, fetches new unread emails from the last 3 days,
    and processes attachments while preserving the original unread status.

    For Gmail:
      - Attempts to select the localized All Mail folder.
      - Runs an X-GM-RAW query: "in:anywhere in:unread newer_than:3d has:attachment".

    For non-Gmail mailboxes, it falls back to selecting the INBOX with a SINCE/UNSEEN filter.

    Args:
        owner_id: Optional user identifier. When provided, ingested documents are
                  attributed to this user via ``process_document`` / ``convert_to_pdf``.
        gmail_apply_labels: Whether to apply Gmail-specific labels and stars to
                  processed emails. Only relevant for Gmail hosts. Defaults to True.
        allowed_categories: List of file-type category keys to ingest (e.g.
                  ``["pdf", "office", "images"]``).  ``None`` falls back to the
                  global ``settings.imap_attachment_filter`` mapping.
    """
    if allowed_categories is None:
        allowed_categories = _resolve_categories_for_profile(None)

    effective_mime_types, effective_extensions = get_allowed_types_for_categories(allowed_categories)
    logger.info(
        "Connecting to %s at %s:%s (SSL=%s) — categories: %s",
        mailbox_key,
        host,
        port,
        use_ssl,
        allowed_categories,
    )
    processed_emails = load_processed_emails()

    try:
        mail = imaplib.IMAP4_SSL(host, port) if use_ssl else imaplib.IMAP4(host, port)
        mail.login(username, password)

        is_gmail_host = "gmail" in host.lower()
        if is_gmail_host:
            # For Gmail, try to select the localized All Mail folder.
            all_mail_folder = find_all_mail_folder(mail)
            if all_mail_folder:
                logger.info("Using Gmail All Mail folder: %s", all_mail_folder)
                mail.select(f'"{all_mail_folder}"')
            else:
                logger.warning("Gmail All Mail folder not found, falling back to INBOX.")
                mail.select("INBOX")
            # Use the X-GM-RAW query for Gmail.
            raw_query = "in:anywhere in:unread newer_than:3d has:attachment"
            status, search_data = mail.search(None, "X-GM-RAW", f'"{raw_query}"')
        else:
            # For non-Gmail, select INBOX and use SINCE/UNSEEN query.
            mail.select("INBOX")
            since_date = (datetime.now(timezone.utc) - timedelta(days=3)).strftime("%d-%b-%Y")
            status, search_data = mail.search(None, f"(SINCE {since_date} UNSEEN)")

        if status != "OK":
            logger.warning("Search failed on mailbox %s. Status=%s", mailbox_key, status)
            mail.close()
            mail.logout()
            return

        msg_numbers = search_data[0].split()
        logger.info("Found %d unread emails in %s.", len(msg_numbers), mailbox_key)

        for num in msg_numbers:
            status, msg_data = mail.fetch(num, "(RFC822)")
            if status != "OK":
                logger.warning("Failed to fetch message %s in %s. Status=%s", num, mailbox_key, status)
                continue

            raw_email = msg_data[0][1]
            email_message = email.message_from_bytes(raw_email)
            msg_id = email_message.get("Message-ID")

            if not msg_id:
                logger.warning("Skipping email without Message-ID in %s", mailbox_key)
                continue

            if msg_id in processed_emails:
                logger.info("Skipping already processed email %s in %s", msg_id, mailbox_key)
                continue

            # For Gmail, check if the email already has the "Ingested" label.
            if is_gmail_host and gmail_apply_labels:
                if email_already_has_label(mail, num, "Ingested"):
                    logger.info("Skipping email %s in %s, already labeled 'Ingested'.", msg_id, mailbox_key)
                    continue

            # Process attachments using the resolved mime types / extensions.
            fetch_attachments_and_enqueue(
                email_message,
                owner_id=owner_id,
                effective_mime_types=effective_mime_types,
                effective_extensions=effective_extensions,
            )

            if settings.imap_readonly_mode:
                logger.info("Readonly mode: skipping mailbox modifications for %s in %s", msg_id, mailbox_key)
            else:
                if is_gmail_host and gmail_apply_labels:
                    mark_as_processed_with_star(mail, num)
                    mark_as_processed_with_label(mail, num, label="Ingested")

                if delete_after_process:
                    logger.info("Deleting message %s from %s", num.decode(), mailbox_key)
                    mail.store(num, "+FLAGS", "\\Deleted")
                else:
                    mail.store(num, "-FLAGS", "\\Seen")

            processed_emails[msg_id] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
            save_processed_emails(processed_emails)

        if not settings.imap_readonly_mode and delete_after_process:
            mail.expunge()

        mail.close()
        mail.logout()
        logger.info("Finished processing mailbox %s", mailbox_key)

    except Exception as e:
        logger.exception("Error pulling mailbox %s: %s", mailbox_key, e)


def fetch_attachments_and_enqueue(
    email_message,
    owner_id: str | None = None,
    effective_mime_types: frozenset[str] | None = None,
    effective_extensions: frozenset[str] | None = None,
):
    """
    Extracts attachments from the email and processes only allowed file types.

    The caller is responsible for computing ``effective_mime_types`` and
    ``effective_extensions`` from the relevant :class:`ImapIngestionProfile` (or
    the global default) via :func:`app.utils.allowed_types.get_allowed_types_for_categories`
    before calling this function.  ``pull_inbox`` does this automatically.

    If either set is ``None`` the function falls back to the default category list
    so the function still works correctly when called directly in tests or from
    other contexts.

    If the attachment is a PDF (by extension or MIME type), it is enqueued for upload;
    any other allowed file is enqueued for conversion to PDF.

    Args:
        email_message: The parsed email message to extract attachments from.
        owner_id: Optional user identifier forwarded to ``process_document`` /
                  ``convert_to_pdf`` for multi-tenant attribution.
        effective_mime_types: Pre-computed frozenset of allowed MIME type strings.
        effective_extensions: Pre-computed frozenset of allowed file extension strings.

    Returns True if at least one allowed attachment was processed.
    """
    if effective_mime_types is None or effective_extensions is None:
        effective_mime_types, effective_extensions = get_allowed_types_for_categories(DEFAULT_CATEGORIES)

    has_attachment = False
    for part in email_message.walk():
        if part.get_content_maintype() == "multipart":
            continue

        filename = part.get_filename()
        if not filename:
            continue

        # Check if it's a PDF file by extension, regardless of MIME type
        is_pdf_by_extension = filename.lower().endswith(".pdf")

        mime_type = part.get_content_type()
        file_ext = os.path.splitext(filename)[1].lower()

        # Accept file if it has an allowed MIME type, an allowed extension, OR is a PDF by extension
        if mime_type not in effective_mime_types and file_ext not in effective_extensions and not is_pdf_by_extension:
            logger.info(
                "Skipping attachment %s (MIME: %s, ext: %s) — not in effective allowed set",
                filename,
                mime_type,
                file_ext,
            )
            continue

        file_path = os.path.join(settings.workdir, filename)
        with open(file_path, "wb") as f:
            f.write(part.get_payload(decode=True))

        # If it's a PDF by MIME type or extension, process it directly
        if mime_type == "application/pdf" or is_pdf_by_extension:
            process_document.delay(file_path, owner_id=owner_id)
            logger.info("Enqueued PDF for upload: %s (MIME: %s)", filename, mime_type)
        elif mime_type in effective_mime_types:
            # Other allowed files are sent for conversion
            convert_to_pdf.delay(file_path, owner_id=owner_id)
            logger.info("Enqueued file for conversion to PDF: %s", filename)

        has_attachment = True
    return has_attachment


def email_already_has_label(mail, msg_id, label="Ingested"):
    """
    Checks if the given message (msg_id) has the specified Gmail label.
    Returns True if the label is found, False otherwise.
    """
    try:
        # Convert msg_id to bytes if it's an integer
        if isinstance(msg_id, int):
            msg_id = str(msg_id).encode()

        label_status, label_data = mail.fetch(msg_id, "(X-GM-LABELS)")
        if label_status == "OK" and label_data and len(label_data) > 0:
            raw_labels = label_data[0][1].decode("utf-8", errors="ignore")
            if label in raw_labels:
                return True
    except Exception as e:
        logger.error("Failed to fetch labels for msg_id=%s: %s", msg_id, e)
    return False


def mark_as_processed_with_star(mail, msg_id):
    """Stars the email in Gmail."""
    try:
        mail.store(msg_id, "+FLAGS", "\\Flagged")
        logger.info("Email %s starred in Gmail.", msg_id)
    except Exception as e:
        logger.error("Failed to star email %s: %s", msg_id, e)


def mark_as_processed_with_label(mail, msg_id, label="Ingested"):
    """Adds a custom label to the email in Gmail."""
    try:
        mail.store(msg_id, "+X-GM-LABELS", label)
        logger.info("Email %s labeled '%s' in Gmail.", msg_id, label)
    except Exception as e:
        logger.error("Failed to label email %s with %s: %s", msg_id, label, e)


def find_all_mail_folder(mail):
    """
    Attempts to select the Gmail All Mail folder using known localized names.
    Falls back to using XLIST if needed.
    Returns the folder name if found, otherwise None.
    """
    COMMON_ALL_MAIL_NAMES = [
        "[Gmail]/Alle Nachrichten",
        "[Gmail]/All Mail",
        "[Gmail]/Todos",
        "[Gmail]/Tutte le mail",
        "[Gmail]/Tous les messages",
    ]
    for candidate in COMMON_ALL_MAIL_NAMES:
        status, _ = mail.select(f'"{candidate}"', readonly=True)
        if status == "OK":
            return candidate

    capabilities = get_capabilities(mail)
    if "XLIST" in capabilities:
        candidate = find_all_mail_xlist(mail)
        if candidate:
            return candidate
    return None


def get_capabilities(mail):
    """Returns a list of capabilities supported by the IMAP server."""
    typ, data = mail.capability()
    if typ == "OK" and data:
        caps = data[0].decode("utf-8", errors="ignore").upper().split()
        return caps
    return []


def find_all_mail_xlist(mail):
    """
    Uses XLIST to discover the mailbox flagged as All Mail.
    Returns the folder name if found, otherwise None.
    """
    tag = mail._new_tag().decode("ascii")
    command_str = f'{tag} XLIST "" "*"'
    mail.send((command_str + "\r\n").encode("utf-8"))

    all_mail_folder = None
    while True:
        line = mail.readline()
        if not line:
            break
        line_str = line.decode("utf-8", errors="ignore").strip()
        if line_str.upper().startswith("* XLIST ") and "\\ALLMAIL" in line_str.upper():
            match = re.search(r'"([^"]+)"$', line_str)
            if match:
                candidate = match.group(1)
                logger.info("Found All Mail folder via XLIST: %s", candidate)
                all_mail_folder = candidate
        if line_str.startswith(tag):
            break
    return all_mail_folder
