"""Authorized PChome sales report providers and file-boundary guards."""

from __future__ import annotations

import hashlib
import http.client
import imaplib
import ipaddress
import os
import re
import shutil
import socket
import ssl
import tempfile
from dataclasses import dataclass
from email import policy
from email.header import decode_header, make_header
from email.parser import BytesParser
from pathlib import Path
from typing import Callable, List, Optional
from urllib.parse import unquote, urlparse


VALID_EXTENSIONS = {".xlsx", ".xls"}
XLSX_MAGIC = b"PK\x03\x04"
XLS_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"


class ProviderError(RuntimeError):
    def __init__(self, kind: str, public_message: str):
        super().__init__(public_message)
        self.kind = kind
        self.public_message = public_message


class _PinnedHTTPSConnection(http.client.HTTPSConnection):
    """Connect to the validated IP while preserving hostname TLS verification."""

    def __init__(self, hostname: str, port: int, pinned_ip: str, timeout: int):
        super().__init__(hostname, port=port, timeout=timeout, context=ssl.create_default_context())
        self._pinned_ip = pinned_ip

    def connect(self):
        self.sock = socket.create_connection(
            (self._pinned_ip, self.port),
            self.timeout,
            self.source_address,
        )
        self.sock = self._context.wrap_socket(self.sock, server_hostname=self.host)


@dataclass
class AcquisitionCandidate:
    source_type: str
    file_path: str
    file_name: str
    fingerprint: str
    source_ref_hash: str
    finalize_success: Callable[[], None]
    finalize_rejected: Callable[[], None]
    cleanup: Callable[[], None]


def env_bool(name: str, default: bool = False) -> bool:
    fallback = "true" if default else "false"
    return os.getenv(name, fallback).strip().lower() in {"1", "true", "yes", "on"}


def env_int(name: str, default: int, minimum: int = 1, maximum: int = 1000) -> int:
    try:
        value = int(os.getenv(name, str(default)))
    except (TypeError, ValueError):
        value = default
    return max(minimum, min(value, maximum))


def _hash_text(value: str) -> str:
    return hashlib.sha256(value.encode("utf-8", errors="ignore")).hexdigest()


def _sha256_file(file_path: str) -> str:
    digest = hashlib.sha256()
    with open(file_path, "rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def _safe_file_name(value: str, default: str = "pchome-sales.xlsx") -> str:
    candidate = os.path.basename(str(value or "").replace("\x00", "")).strip()
    candidate = re.sub(r"[^0-9A-Za-z._()\-\u4e00-\u9fff]+", "_", candidate)
    return (candidate or default)[:180]


def _decode_mail_header(value: Optional[str]) -> str:
    if not value:
        return ""
    try:
        return str(make_header(decode_header(value)))
    except Exception:
        return str(value)


def _make_temp_path(suffix: str) -> str:
    target = Path(os.getenv("PCHOME_SALES_TEMP_DIR", "data/temp/pchome-sales"))
    target.mkdir(parents=True, exist_ok=True)
    fd, file_path = tempfile.mkstemp(prefix="sales-", suffix=suffix, dir=str(target))
    os.close(fd)
    return file_path


def _validate_excel_file(file_path: str, file_name: str) -> None:
    path = Path(file_path)
    max_bytes = env_int("PCHOME_SALES_MAX_FILE_BYTES", 25 * 1024 * 1024, 1024, 200 * 1024 * 1024)
    if not path.is_file() or path.is_symlink():
        raise ProviderError("unsafe_file", "來源檔案不存在或不符合安全檔案規則。")
    if path.stat().st_size <= 0 or path.stat().st_size > max_bytes:
        raise ProviderError("invalid_file_size", "來源檔案大小不符合匯入政策，未執行寫入。")
    suffix = Path(file_name).suffix.lower()
    if suffix not in VALID_EXTENSIONS:
        raise ProviderError("unsupported_file_type", "來源不是允許的 Excel 業績檔，未執行寫入。")
    with path.open("rb") as handle:
        magic = handle.read(8)
    signature_matches = (
        suffix == ".xlsx" and magic.startswith(XLSX_MAGIC)
    ) or (
        suffix == ".xls" and magic == XLS_MAGIC
    )
    if not signature_matches:
        raise ProviderError("invalid_excel_signature", "來源檔案內容不是有效的 Excel 格式，未執行寫入。")


def _archive_file(source: Path, folder_name: str, fingerprint: str) -> None:
    if not source.exists() or source.is_symlink():
        return
    target_dir = source.parent / folder_name
    target_dir.mkdir(parents=True, exist_ok=True)
    target = target_dir / source.name
    if target.exists():
        target = target_dir / f"{source.stem}-{fingerprint[:10]}{source.suffix}"
    os.replace(str(source), str(target))


class AuthorizedSalesProviders:
    """Side-effect-bounded report providers in governed priority order."""

    def validate_http_url(self, url: str) -> tuple[str, int, str]:
        parsed = urlparse(url)
        if parsed.scheme.lower() != "https" or not parsed.hostname or parsed.username or parsed.password:
            raise ProviderError("http_url_rejected", "授權報表網址必須是無內嵌帳密的 HTTPS 網址。")
        hostname = parsed.hostname.lower().rstrip(".")
        try:
            port = parsed.port or 443
        except ValueError as exc:
            raise ProviderError("http_port_rejected", "授權報表網址的連接埠格式無效。") from exc
        allowed_ports = {
            int(item.strip())
            for item in os.getenv("PCHOME_SALES_HTTP_ALLOWED_PORTS", "443").split(",")
            if item.strip().isdigit()
        }
        if port not in allowed_ports:
            raise ProviderError("http_port_rejected", "授權報表連接埠未列入允許清單，未發出連線。")
        allowed_hosts = {
            item.strip().lower().rstrip(".")
            for item in os.getenv("PCHOME_SALES_HTTP_ALLOWED_HOSTS", "").split(",")
            if item.strip()
        }
        if hostname not in allowed_hosts:
            raise ProviderError("http_host_not_allowed", "授權報表主機未列入允許清單，未發出連線。")
        try:
            addresses = {
                item[4][0]
                for item in socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM)
            }
        except OSError as exc:
            raise ProviderError("http_dns_failed", "授權報表主機目前無法解析。") from exc
        if not addresses:
            raise ProviderError("http_dns_failed", "授權報表主機目前沒有可用位址。")
        if not env_bool("PCHOME_SALES_HTTP_ALLOW_PRIVATE"):
            for address in addresses:
                ip = ipaddress.ip_address(address)
                if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
                    raise ProviderError("http_private_address_rejected", "授權報表主機解析到受限制網段，未發出連線。")
        return hostname, port, sorted(addresses)[0]

    def http_candidates(self) -> List[AcquisitionCandidate]:
        if not env_bool("PCHOME_SALES_HTTP_ENABLED"):
            return []
        url = os.getenv("PCHOME_SALES_HTTP_URL", "").strip()
        if not url:
            raise ProviderError("http_not_configured", "授權 HTTPS 來源已啟用但網址尚未設定。")
        hostname, port, pinned_ip = self.validate_http_url(url)
        parsed = urlparse(url)
        headers = {"Accept": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"}
        token = os.getenv("PCHOME_SALES_HTTP_BEARER_TOKEN", "").strip()
        if token:
            headers["Authorization"] = f"Bearer {token}"
        timeout = env_int("PCHOME_SALES_HTTP_TIMEOUT_SECONDS", 30, 3, 120)
        max_bytes = env_int("PCHOME_SALES_MAX_FILE_BYTES", 25 * 1024 * 1024, 1024, 200 * 1024 * 1024)
        connection = _PinnedHTTPSConnection(hostname, port, pinned_ip, timeout)
        path = parsed.path or "/"
        if parsed.query:
            path = f"{path}?{parsed.query}"
        try:
            connection.request("GET", path, headers=headers)
            response = connection.getresponse()
            if response.status != 200:
                raise ProviderError("http_status_rejected", f"授權 HTTPS 報表來源回應 {response.status}，未執行匯入。")
            content_length = response.getheader("Content-Length")
            if content_length and int(content_length) > max_bytes:
                raise ProviderError("http_file_too_large", "授權報表超過檔案大小上限，未執行匯入。")
            disposition = response.getheader("Content-Disposition", "")
            match = re.search(r"filename\*?=(?:UTF-8''|\")?([^\";]+)", disposition, re.I)
            raw_name = unquote(match.group(1).strip()) if match else os.path.basename(parsed.path)
            file_name = _safe_file_name(raw_name)
            suffix = Path(file_name).suffix.lower()
            if suffix not in VALID_EXTENSIONS:
                suffix = ".xlsx"
                file_name = f"{Path(file_name).stem or 'pchome-sales'}{suffix}"
            temp_path = _make_temp_path(suffix)
            written = 0
            try:
                with open(temp_path, "wb") as handle:
                    while True:
                        chunk = response.read(1024 * 1024)
                        if not chunk:
                            break
                        written += len(chunk)
                        if written > max_bytes:
                            raise ProviderError("http_file_too_large", "授權報表超過檔案大小上限，未執行匯入。")
                        handle.write(chunk)
                _validate_excel_file(temp_path, file_name)
            except Exception:
                Path(temp_path).unlink(missing_ok=True)
                raise
        except ProviderError:
            raise
        except (OSError, ssl.SSLError, http.client.HTTPException, ValueError) as exc:
            raise ProviderError("http_fetch_failed", "授權 HTTPS 報表來源目前無法連線。") from exc
        finally:
            connection.close()
        fingerprint = _sha256_file(temp_path)
        return [AcquisitionCandidate(
            source_type="authorized_https",
            file_path=temp_path,
            file_name=file_name,
            fingerprint=fingerprint,
            source_ref_hash=_hash_text(f"https://{hostname}{parsed.path}"),
            finalize_success=lambda: None,
            finalize_rejected=lambda: None,
            cleanup=lambda candidate_path=temp_path: Path(candidate_path).unlink(missing_ok=True),
        )]

    def _mark_imap_seen(self, uid: bytes) -> None:
        host = os.getenv("PCHOME_SALES_IMAP_HOST", "").strip()
        user = os.getenv("PCHOME_SALES_IMAP_USER", "").strip()
        password = os.getenv("PCHOME_SALES_IMAP_PASSWORD", "")
        port = env_int("PCHOME_SALES_IMAP_PORT", 993, 1, 65535)
        mailbox = os.getenv("PCHOME_SALES_IMAP_MAILBOX", "INBOX").strip() or "INBOX"
        client = imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context(), timeout=20)
        try:
            client.login(user, password)
            client.select(mailbox, readonly=False)
            client.uid("store", uid, "+FLAGS", "(\\Seen)")
        finally:
            try:
                client.logout()
            except Exception:
                pass

    def imap_candidates(self) -> List[AcquisitionCandidate]:
        if not env_bool("PCHOME_SALES_IMAP_ENABLED"):
            return []
        host = os.getenv("PCHOME_SALES_IMAP_HOST", "").strip()
        user = os.getenv("PCHOME_SALES_IMAP_USER", "").strip()
        password = os.getenv("PCHOME_SALES_IMAP_PASSWORD", "")
        if not host or not user or not password:
            raise ProviderError("imap_not_configured", "授權信箱來源已啟用但連線設定不完整。")
        port = env_int("PCHOME_SALES_IMAP_PORT", 993, 1, 65535)
        mailbox = os.getenv("PCHOME_SALES_IMAP_MAILBOX", "INBOX").strip() or "INBOX"
        subject_pattern = os.getenv("PCHOME_SALES_IMAP_SUBJECT", "即時業績").strip()
        max_messages = env_int("PCHOME_SALES_IMAP_MAX_MESSAGES", 20, 1, 100)
        candidates: List[AcquisitionCandidate] = []
        client = None
        try:
            client = imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context(), timeout=20)
            client.login(user, password)
            status, _ = client.select(mailbox, readonly=True)
            if status != "OK":
                raise ProviderError("imap_mailbox_failed", "授權信箱資料夾目前無法開啟。")
            status, data = client.uid("search", None, "UNSEEN")
            if status != "OK":
                raise ProviderError("imap_search_failed", "授權信箱目前無法搜尋新報表。")
            uids = list(reversed((data[0] or b"").split()))[:max_messages]
            for uid in uids:
                status, payload = client.uid("fetch", uid, "(RFC822)")
                if status != "OK" or not payload or not isinstance(payload[0], tuple):
                    continue
                message = BytesParser(policy=policy.default).parsebytes(payload[0][1])
                if subject_pattern and subject_pattern not in _decode_mail_header(message.get("Subject")):
                    continue
                for attachment in message.iter_attachments():
                    file_name = _safe_file_name(_decode_mail_header(attachment.get_filename()))
                    suffix = Path(file_name).suffix.lower()
                    if suffix not in VALID_EXTENSIONS:
                        continue
                    temp_path = _make_temp_path(suffix)
                    try:
                        with open(temp_path, "wb") as handle:
                            handle.write(attachment.get_payload(decode=True) or b"")
                        _validate_excel_file(temp_path, file_name)
                    except Exception:
                        Path(temp_path).unlink(missing_ok=True)
                        raise
                    fingerprint = _sha256_file(temp_path)
                    candidates.append(AcquisitionCandidate(
                        source_type="authorized_imap",
                        file_path=temp_path,
                        file_name=file_name,
                        fingerprint=fingerprint,
                        source_ref_hash=_hash_text(f"{host}:{mailbox}:{uid.decode(errors='ignore')}"),
                        finalize_success=lambda message_uid=uid: self._mark_imap_seen(message_uid),
                        finalize_rejected=lambda message_uid=uid: self._mark_imap_seen(message_uid),
                        cleanup=lambda candidate_path=temp_path: Path(candidate_path).unlink(missing_ok=True),
                    ))
        except ProviderError:
            for candidate in candidates:
                candidate.cleanup()
            raise
        except (imaplib.IMAP4.error, OSError, ssl.SSLError) as exc:
            for candidate in candidates:
                candidate.cleanup()
            raise ProviderError("imap_connection_failed", "授權信箱來源目前無法連線或驗證。") from exc
        finally:
            if client is not None:
                try:
                    client.logout()
                except Exception:
                    pass
        return candidates

    def local_candidates(self) -> List[AcquisitionCandidate]:
        configured = os.getenv("PCHOME_SALES_LOCAL_DROP_DIR", "").strip()
        if not configured:
            return []
        root = Path(configured).expanduser().resolve()
        if not root.is_dir():
            raise ProviderError("local_drop_missing", "受控落地目錄不存在，未執行檔案存取。")
        max_files = env_int("PCHOME_SALES_MAX_FILES_PER_RUN", 5, 1, 25)
        sources = sorted(
            (
                path for path in root.iterdir()
                if path.is_file() and not path.is_symlink() and path.suffix.lower() in VALID_EXTENSIONS
            ),
            key=lambda path: path.stat().st_mtime,
            reverse=True,
        )[:max_files]
        candidates: List[AcquisitionCandidate] = []
        for source in sources:
            temp_path = _make_temp_path(source.suffix.lower())
            try:
                shutil.copyfile(str(source), temp_path)
                _validate_excel_file(temp_path, source.name)
            except Exception:
                Path(temp_path).unlink(missing_ok=True)
                for candidate in candidates:
                    candidate.cleanup()
                raise
            fingerprint = _sha256_file(temp_path)
            candidates.append(AcquisitionCandidate(
                source_type="controlled_local_drop",
                file_path=temp_path,
                file_name=_safe_file_name(source.name),
                fingerprint=fingerprint,
                source_ref_hash=_hash_text(str(source)),
                finalize_success=lambda item=source, fp=fingerprint: _archive_file(item, "archive", fp),
                finalize_rejected=lambda item=source, fp=fingerprint: _archive_file(item, "quarantine", fp),
                cleanup=lambda candidate_path=temp_path: Path(candidate_path).unlink(missing_ok=True),
            ))
        return candidates
