import logging
import os
import re
import shutil
import subprocess
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Iterable

from fastapi import HTTPException
from passlib.context import CryptContext

from .settings import get_settings

logger = logging.getLogger(__name__)

# Use argon2id for new hashes; keep bcrypt/pbkdf2 for legacy verification.
pwd_context = CryptContext(
    bcrypt__truncate_error=True,
    schemes=["argon2", "bcrypt", "pbkdf2_sha512"],
    deprecated="auto",
    default="argon2",
    argon2__type="ID",
)
DOMAIN_RE = re.compile(
    r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$"
)
LOCAL_PART_RE = re.compile(r"^(?=.{1,64}$)[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$")
SELECTOR_RE = re.compile(r"^[A-Za-z0-9_-]{1,63}$")


def hash_password(password: str) -> str:
    return pwd_context.hash(password)


def _mail_home() -> Path | None:
    """Return configured mail home if available, otherwise None."""
    try:
        settings = get_settings()
        mail_home = getattr(settings, "mail_home", None)
        if not mail_home:
            return None
        return Path(mail_home).resolve(strict=False)
    except Exception:
        return None


def mailbox_path_for(domain: str, local_part: str) -> Path:
    """Return the absolute Maildir path for a virtual mailbox."""
    base = _mail_home()
    if base is None:
        raise ValueError("Mail home is not configured")
    safe_domain = normalize_domain(domain)
    safe_local = validate_local_part(local_part)
    path = (base / safe_domain / safe_local).resolve(strict=False)
    try:
        path.relative_to(base)
    except ValueError as exc:
        raise ValueError("Mailbox path escapes mail home") from exc
    return path


def remove_mailbox(domain: str, local_part: str) -> bool:
    """Best-effort removal of a virtual mailbox directory under mail_home."""
    try:
        path = mailbox_path_for(domain, local_part)
    except ValueError:
        return False
    if not path.exists():
        return False
    try:
        shutil.rmtree(path)
        logger.info("Removed mailbox directory %s", path)
        return True
    except OSError as exc:
        logger.warning("Unable to remove mailbox directory %s: %s", path, exc)
        return False


def remove_domain_mailboxes(domain: str) -> bool:
    """Best-effort removal of an entire domain mail tree under mail_home."""
    base = _mail_home()
    if base is None:
        return False
    try:
        safe_domain = normalize_domain(domain)
    except ValueError:
        return False
    path = (base / safe_domain).resolve(strict=False)
    try:
        path.relative_to(base)
    except ValueError:
        return False
    if path == base or not path.exists():
        return False
    try:
        shutil.rmtree(path)
        logger.info("Removed domain mailbox tree %s", path)
        return True
    except OSError as exc:
        logger.warning("Unable to remove domain mailbox tree %s: %s", path, exc)
        return False


def verify_password(password: str, hashed: str) -> bool:
    # bcrypt raises on >72-byte secrets; truncate for legacy bcrypt hashes.
    if hashed.startswith("$2"):
        secret = password.encode("utf-8")
        if len(secret) > 72:
            password = secret[:72].decode("utf-8", errors="ignore")
    return pwd_context.verify(password, hashed)


def normalize_domain(value: str) -> str:
    candidate = value.strip().rstrip(".").lower()
    try:
        normalized = candidate.encode("idna").decode("ascii")
    except UnicodeError as exc:
        raise ValueError("Invalid domain name") from exc
    if not DOMAIN_RE.fullmatch(normalized):
        raise ValueError("Invalid domain name")
    return normalized


def validate_local_part(value: str) -> str:
    candidate = value.strip()
    if not LOCAL_PART_RE.fullmatch(candidate) or candidate.startswith(".") or candidate.endswith(".") or ".." in candidate:
        raise ValueError("Invalid local part")
    return candidate.lower()


def validate_selector(value: str) -> str:
    candidate = value.strip().lower()
    if not SELECTOR_RE.fullmatch(candidate):
        raise ValueError("Invalid DKIM selector")
    return candidate


def build_dkim_key_path(domain: str, selector: str, base_dir: Path) -> Path:
    try:
        safe_domain = normalize_domain(domain)
        safe_selector = validate_selector(selector)
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    base_path = base_dir.resolve(strict=False)
    key_path = (base_path / f"{safe_domain}.{safe_selector}.key").resolve(strict=False)
    try:
        key_path.relative_to(base_path)
    except ValueError as exc:
        raise HTTPException(status_code=400, detail="Invalid DKIM path") from exc
    return key_path


def remove_managed_dkim_key(raw_path: str | Path | None) -> bool:
    if not raw_path:
        return False
    base_dir = get_settings().dkim_keys_dir.resolve(strict=False)
    candidate = Path(raw_path).resolve(strict=False)
    try:
        candidate.relative_to(base_dir)
    except ValueError:
        return False
    if candidate.suffix != ".key":
        return False
    try:
        candidate.unlink(missing_ok=True)
    except OSError:
        return False
    return True


def _atomic_write(path: Path, content: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as tmp_file:
        tmp_file.write(content)
        tmp_path = Path(tmp_file.name)
    os.replace(tmp_path, path)
    path.chmod(0o640)


def sync_dkim_signing_maps(domains: Iterable[object]) -> None:
    """Write Rspamd selector/path maps for active domain keys.

    Only the *current* selector is used for signing. Previous keys are kept on
    disk/DNS for verification during the configured overlap window, but are not
    used for new signatures.
    """
    settings = get_settings()
    base_dir = settings.dkim_keys_dir.resolve(strict=False)
    selector_lines: list[str] = []
    path_lines: list[str] = []

    for domain in domains:
        raw_path = getattr(domain, "dkim_private_path", None)
        if not raw_path:
            continue
        safe_domain = normalize_domain(getattr(domain, "name", ""))
        safe_selector = validate_selector(getattr(domain, "dkim_selector", "default") or "default")
        key_path = Path(str(raw_path)).resolve(strict=False)
        try:
            key_path.relative_to(base_dir)
        except ValueError:
            continue
        selector_lines.append(f"{safe_domain} {safe_selector}")
        path_lines.append(f"{safe_domain} {key_path}")

    if not selector_lines and not path_lines and not base_dir.exists():
        return

    _atomic_write(base_dir / "selectors.map", "\n".join(selector_lines) + ("\n" if selector_lines else ""))
    _atomic_write(base_dir / "paths.map", "\n".join(path_lines) + ("\n" if path_lines else ""))


def extract_dkim_dns_record(output: str) -> str | None:
    if not output.strip():
        return None
    normalized = output.replace("\r", "\n")
    squashed = " ".join(normalized.split())
    compact = re.sub(r'"\s+"', "", squashed)
    compact = compact.replace('"', "").replace("(", " ").replace(")", " ")
    compact = re.sub(r"\s+", " ", compact).strip()
    match = re.search(r"v=DKIM1\s*;.*?\bp\s*=\s*[A-Za-z0-9+/=]+", compact, flags=re.IGNORECASE)
    if not match:
        return None
    record = match.group(0)
    record = re.sub(r"\s*;\s*", "; ", record)
    record = re.sub(r"\s*=\s*", "=", record)
    return record.strip()


def generate_dkim_key(domain: str, selector: str = "default") -> dict:
    settings = get_settings()
    try:
        safe_domain = normalize_domain(domain)
        safe_selector = validate_selector(selector)
        key_path = build_dkim_key_path(safe_domain, safe_selector, settings.dkim_keys_dir)
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    key_path.parent.mkdir(parents=True, exist_ok=True)
    cmd = ["rspamadm", "dkim_keygen", "-d", safe_domain, "-s", safe_selector, "-k", str(key_path)]
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, check=True)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=500, detail="rspamadm is not available on this host") from exc
    except subprocess.CalledProcessError as exc:
        detail = (exc.stderr or exc.stdout or "").strip() or f"rspamadm exited with status {exc.returncode}"
        raise HTTPException(status_code=500, detail=f"DKIM keygen failed: {detail}") from exc
    if key_path.exists():
        key_path.chmod(0o640)
    dns_record = extract_dkim_dns_record("\n".join(part for part in [result.stdout, result.stderr] if part))
    if not dns_record:
        raise HTTPException(status_code=500, detail="DKIM key generated but DNS record output could not be parsed")
    return {"path": str(key_path), "dns": dns_record, "selector": safe_selector}


def timestamp_dkim_selector(prefix: str = "s") -> str:
    """Build a unique DKIM selector suitable for scheduled rotations."""
    from datetime import UTC, datetime

    stamp = datetime.now(UTC).strftime("%Y%m%d%H%M")
    candidate = f"{prefix}{stamp}"
    return validate_selector(candidate[:63])


def default_dkim_rotation_interval_days() -> int:
    import os

    try:
        return max(0, int(os.getenv("LIMRISTEM_MAIL_DKIM_ROTATION_INTERVAL_DAYS", "90")))
    except ValueError:
        return 90


def default_dkim_overlap_days() -> int:
    import os

    try:
        return max(0, int(os.getenv("LIMRISTEM_MAIL_DKIM_OVERLAP_DAYS", "14")))
    except ValueError:
        return 14


def resolve_domain_rotation_interval_days(domain: object) -> int:
    value = int(getattr(domain, "dkim_rotation_interval_days", 0) or 0)
    return value if value > 0 else default_dkim_rotation_interval_days()


def resolve_domain_overlap_days(domain: object) -> int:
    value = int(getattr(domain, "dkim_overlap_days", 0) or 0)
    return value if value > 0 else default_dkim_overlap_days()
