from __future__ import annotations

import http.client
import ipaddress
import os
import socket
import ssl
import zipfile
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from io import BytesIO
from typing import Any
from urllib.parse import SplitResult, urlsplit
from uuid import uuid4

import jwt
from sqlalchemy import update
from sqlalchemy.orm import Session

from app.api.deps import CurrentUserContext
from app.models.knowledge_security import KnowledgeOnlyOfficeSession
from app.services.knowledge_tenant_scope import (
    PLATFORM_KNOWLEDGE_SCOPE,
    TENANT_KNOWLEDGE_SCOPE,
    require_knowledge_tenant_id,
)
from app.services.settings import resolve_onlyoffice_settings

ONLYOFFICE_TOKEN_AUDIENCE = "onlyoffice-document-server"
ONLYOFFICE_TOKEN_ISSUER = "x-financial"
ONLYOFFICE_CONTENT_SCOPE = "onlyoffice-content"
ONLYOFFICE_CALLBACK_SCOPE = "onlyoffice-callback"
DEFAULT_CONTENT_TOKEN_TTL_SECONDS = 300
DEFAULT_CALLBACK_SESSION_TTL_SECONDS = 4 * 60 * 60
MAX_CALLBACK_SESSION_TTL_SECONDS = 12 * 60 * 60
DEFAULT_MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024
MAX_DOWNLOAD_BYTES_CEILING = 100 * 1024 * 1024
_OOXML_REQUIRED_ENTRY = {
    "docx": "word/",
    "xlsx": "xl/",
    "pptx": "ppt/",
}
_OOXML_MIME_TYPES = {
    "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
}
_GENERIC_BINARY_MIME_TYPES = {"application/octet-stream", "application/zip"}


class OnlyOfficeSecurityError(ValueError):
    pass


class OnlyOfficeReplayError(OnlyOfficeSecurityError):
    pass


@dataclass(frozen=True, slots=True)
class OnlyOfficeSessionTokens:
    jti: str
    content_token: str
    callback_token: str
    expires_at: datetime


@dataclass(frozen=True, slots=True)
class OnlyOfficeValidatedSession:
    jti: str
    tenant_id: str | None
    resource_scope: str
    document_id: str
    document_key: str
    document_version: int
    editable: bool
    status: str


class KnowledgeOnlyOfficeSessionService:
    def __init__(self, db: Session) -> None:
        self.db = db

    def issue(
        self,
        *,
        resource_scope: str,
        tenant_id: str | None,
        document_id: str,
        entry: dict[str, Any],
        current_user: CurrentUserContext,
        editable: bool,
    ) -> OnlyOfficeSessionTokens:
        normalized_tenant_id = require_knowledge_tenant_id(tenant_id)
        if resource_scope not in {TENANT_KNOWLEDGE_SCOPE, PLATFORM_KNOWLEDGE_SCOPE}:
            raise OnlyOfficeSecurityError("ONLYOFFICE 资源 scope 不合法。")
        if resource_scope == PLATFORM_KNOWLEDGE_SCOPE and editable:
            raise OnlyOfficeSecurityError("平台知识文档为只读资源，不能进入编辑会话。")

        settings = resolve_onlyoffice_settings(self.db)
        if not settings.jwt_secret:
            raise OnlyOfficeSecurityError("ONLYOFFICE JWT 密钥未配置。")
        now = datetime.now(UTC)
        content_expires_at = now + timedelta(seconds=_content_token_ttl_seconds())
        expires_at = now + timedelta(seconds=_callback_session_ttl_seconds())
        jti = str(uuid4())
        document_key = _build_document_key(entry)
        document_version = int(entry.get("version_number") or 1)
        row = KnowledgeOnlyOfficeSession(
            jti=jti,
            tenant_id=normalized_tenant_id,
            resource_scope=resource_scope,
            document_id=document_id,
            document_key=document_key,
            document_version=document_version,
            audience=ONLYOFFICE_TOKEN_AUDIENCE,
            editable=bool(editable),
            status="active",
            created_by=current_user.username,
            expires_at=expires_at,
            failure_reason="",
        )
        self.db.add(row)
        self.db.commit()

        common_claims = {
            "iss": ONLYOFFICE_TOKEN_ISSUER,
            "aud": ONLYOFFICE_TOKEN_AUDIENCE,
            "sub": document_id,
            "jti": jti,
            "iat": int(now.timestamp()),
            "nbf": int(now.timestamp()),
            "tenant_id": normalized_tenant_id,
            "resource_scope": resource_scope,
            "document_id": document_id,
            "document_key": document_key,
            "document_version": document_version,
            "editable": bool(editable),
        }
        return OnlyOfficeSessionTokens(
            jti=jti,
            content_token=jwt.encode(
                {
                    **common_claims,
                    "scope": ONLYOFFICE_CONTENT_SCOPE,
                    "exp": int(content_expires_at.timestamp()),
                },
                settings.jwt_secret,
                algorithm="HS256",
            ),
            callback_token=jwt.encode(
                {
                    **common_claims,
                    "scope": ONLYOFFICE_CALLBACK_SCOPE,
                    "exp": int(expires_at.timestamp()),
                },
                settings.jwt_secret,
                algorithm="HS256",
            ),
            expires_at=expires_at,
        )

    def validate_content(
        self,
        *,
        document_id: str,
        token: str,
    ) -> OnlyOfficeValidatedSession:
        return self._validate(
            document_id=document_id,
            token=token,
            expected_scope=ONLYOFFICE_CONTENT_SCOPE,
            allowed_statuses={"active"},
        )

    def validate_callback(
        self,
        *,
        document_id: str,
        token: str,
    ) -> OnlyOfficeValidatedSession:
        return self._validate(
            document_id=document_id,
            token=token,
            expected_scope=ONLYOFFICE_CALLBACK_SCOPE,
            allowed_statuses={"active"},
        )

    def claim_callback(
        self,
        *,
        document_id: str,
        token: str,
        payload_document_key: str,
    ) -> OnlyOfficeValidatedSession:
        validated = self.validate_callback(document_id=document_id, token=token)
        if not validated.editable or validated.resource_scope != TENANT_KNOWLEDGE_SCOPE:
            raise OnlyOfficeSecurityError("只读 ONLYOFFICE 会话禁止回写文档。")
        if not payload_document_key or payload_document_key != validated.document_key:
            raise OnlyOfficeSecurityError("ONLYOFFICE 回调文档 key 与编辑会话不一致。")

        now = datetime.now(UTC)
        result = self.db.execute(
            update(KnowledgeOnlyOfficeSession)
            .where(
                KnowledgeOnlyOfficeSession.jti == validated.jti,
                KnowledgeOnlyOfficeSession.status == "active",
                KnowledgeOnlyOfficeSession.expires_at > now,
            )
            .values(status="processing", claimed_at=now)
        )
        if result.rowcount != 1:
            self.db.rollback()
            raise OnlyOfficeReplayError("ONLYOFFICE 回调会话已被使用或已过期。")
        self.db.commit()
        return OnlyOfficeValidatedSession(
            jti=validated.jti,
            tenant_id=validated.tenant_id,
            resource_scope=validated.resource_scope,
            document_id=validated.document_id,
            document_key=validated.document_key,
            document_version=validated.document_version,
            editable=validated.editable,
            status="processing",
        )

    def finish_callback(self, jti: str, *, succeeded: bool, failure_reason: str = "") -> None:
        row = self.db.get(KnowledgeOnlyOfficeSession, jti)
        if row is None or row.status != "processing":
            raise OnlyOfficeReplayError("ONLYOFFICE 回调会话状态不可更新。")
        if succeeded:
            row.status = "consumed"
            row.consumed_at = datetime.now(UTC)
            row.failure_reason = ""
        else:
            row.status = "failed"
            row.failure_reason = str(failure_reason or "callback_failed")[:1000]
        self.db.commit()

    def _validate(
        self,
        *,
        document_id: str,
        token: str,
        expected_scope: str,
        allowed_statuses: set[str],
    ) -> OnlyOfficeValidatedSession:
        settings = resolve_onlyoffice_settings(self.db)
        if not settings.jwt_secret:
            raise OnlyOfficeSecurityError("ONLYOFFICE JWT 密钥未配置。")
        try:
            claims = jwt.decode(
                token,
                settings.jwt_secret,
                algorithms=["HS256"],
                audience=ONLYOFFICE_TOKEN_AUDIENCE,
                issuer=ONLYOFFICE_TOKEN_ISSUER,
                options={
                    "require": [
                        "iss",
                        "aud",
                        "sub",
                        "jti",
                        "iat",
                        "nbf",
                        "exp",
                        "scope",
                        "resource_scope",
                        "document_id",
                        "document_key",
                        "document_version",
                        "editable",
                    ]
                },
            )
        except jwt.PyJWTError as exc:
            raise OnlyOfficeSecurityError("ONLYOFFICE 会话令牌无效或已过期。") from exc

        jti = str(claims.get("jti") or "").strip()
        row = self.db.get(KnowledgeOnlyOfficeSession, jti)
        if row is None:
            raise OnlyOfficeSecurityError("ONLYOFFICE 会话不存在。")
        now = datetime.now(UTC)
        expires_at = _as_utc(row.expires_at)
        claim_tenant_id = claims.get("tenant_id")
        if claim_tenant_id is not None:
            claim_tenant_id = require_knowledge_tenant_id(claim_tenant_id)
        comparisons = (
            claims.get("scope") == expected_scope,
            claims.get("sub") == document_id,
            claims.get("document_id") == document_id == row.document_id,
            claims.get("resource_scope") == row.resource_scope,
            claim_tenant_id == row.tenant_id,
            claims.get("document_key") == row.document_key,
            int(claims.get("document_version") or 0) == row.document_version,
            bool(claims.get("editable")) == row.editable,
            row.audience == ONLYOFFICE_TOKEN_AUDIENCE,
            row.status in allowed_statuses,
            expires_at > now,
        )
        if not all(comparisons):
            if row.status not in allowed_statuses:
                raise OnlyOfficeReplayError("ONLYOFFICE 会话已被使用或已撤销。")
            raise OnlyOfficeSecurityError("ONLYOFFICE 会话与目标文档不匹配。")
        return OnlyOfficeValidatedSession(
            jti=row.jti,
            tenant_id=row.tenant_id,
            resource_scope=row.resource_scope,
            document_id=row.document_id,
            document_key=row.document_key,
            document_version=row.document_version,
            editable=row.editable,
            status=row.status,
        )


def download_onlyoffice_document(download_url: str, *, expected_filename: str) -> bytes:
    parsed, resolved_ip = _validate_download_target(download_url)
    connection = _open_pinned_connection(parsed, resolved_ip)
    try:
        target = parsed.path or "/"
        if parsed.query:
            target = f"{target}?{parsed.query}"
        connection.request(
            "GET",
            target,
            headers={
                "Host": _host_header(parsed),
                "User-Agent": "x-financial-onlyoffice",
                "Accept": "application/octet-stream, application/zip, "
                "application/vnd.openxmlformats-officedocument.*",
            },
        )
        response = connection.getresponse()
        if response.status != 200:
            raise OnlyOfficeSecurityError(
                f"ONLYOFFICE 文档下载失败，状态码 {response.status}。"
            )
        max_bytes = _max_download_bytes()
        declared_size = _safe_int(response.getheader("Content-Length"))
        if declared_size is not None and declared_size > max_bytes:
            raise OnlyOfficeSecurityError("ONLYOFFICE 文档超过允许的大小。")
        content_type = str(response.getheader("Content-Type") or "application/octet-stream")
        content_type = content_type.partition(";")[0].strip().lower()
        _validate_download_mime(content_type, expected_filename)
        content = response.read(max_bytes + 1)
        if len(content) > max_bytes:
            raise OnlyOfficeSecurityError("ONLYOFFICE 文档超过允许的大小。")
        if not content:
            raise OnlyOfficeSecurityError("ONLYOFFICE 返回了空文档。")
        _validate_ooxml_content(content, expected_filename, max_bytes=max_bytes)
        return content
    except (OSError, http.client.HTTPException) as exc:
        raise OnlyOfficeSecurityError("ONLYOFFICE 文档下载连接失败。") from exc
    finally:
        connection.close()


def _validate_download_target(download_url: str) -> tuple[SplitResult, str]:
    parsed = urlsplit(str(download_url or "").strip())
    if parsed.scheme not in {"http", "https"} or not parsed.hostname:
        raise OnlyOfficeSecurityError("ONLYOFFICE 下载 URL 协议或主机不合法。")
    if parsed.username or parsed.password or parsed.fragment:
        raise OnlyOfficeSecurityError("ONLYOFFICE 下载 URL 不允许包含用户信息或片段。")
    origin = _normalized_origin(parsed)
    if origin not in _allowed_download_origins():
        raise OnlyOfficeSecurityError("ONLYOFFICE 下载 URL 不属于已配置的文档服务。")

    try:
        addresses = {
            str(item[4][0])
            for item in socket.getaddrinfo(
                parsed.hostname,
                parsed.port or _default_port(parsed.scheme),
                type=socket.SOCK_STREAM,
            )
            if item[4]
        }
    except OSError as exc:
        raise OnlyOfficeSecurityError("ONLYOFFICE 下载主机无法解析。") from exc
    if not addresses:
        raise OnlyOfficeSecurityError("ONLYOFFICE 下载主机没有可用地址。")
    if any(not ipaddress.ip_address(address).is_global for address in addresses):
        raise OnlyOfficeSecurityError("ONLYOFFICE 下载主机解析到了非公网地址。")
    return parsed, sorted(addresses)[0]


class _PinnedHTTPSConnection(http.client.HTTPSConnection):
    def __init__(self, hostname: str, resolved_ip: str, port: int, timeout: int) -> None:
        super().__init__(hostname, port=port, timeout=timeout, context=ssl.create_default_context())
        self._resolved_ip = resolved_ip

    def connect(self) -> None:
        raw_socket = socket.create_connection(
            (self._resolved_ip, self.port),
            timeout=self.timeout,
            source_address=self.source_address,
        )
        self.sock = self._context.wrap_socket(raw_socket, server_hostname=self.host)


def _open_pinned_connection(
    parsed: SplitResult,
    resolved_ip: str,
) -> http.client.HTTPConnection:
    port = parsed.port or _default_port(parsed.scheme)
    if parsed.scheme == "https":
        return _PinnedHTTPSConnection(parsed.hostname or "", resolved_ip, port, timeout=20)
    return http.client.HTTPConnection(resolved_ip, port=port, timeout=20)


def _allowed_download_origins() -> set[str]:
    configured = str(os.environ.get("ONLYOFFICE_DOWNLOAD_ALLOWED_ORIGINS") or "").strip()
    raw_origins = [item.strip() for item in configured.split(",") if item.strip()]
    if not raw_origins:
        public_url = str(resolve_onlyoffice_settings().public_url or "").strip()
        raw_origins = [public_url] if public_url else []
    origins: set[str] = set()
    for raw_origin in raw_origins:
        parsed = urlsplit(raw_origin)
        if parsed.scheme in {"http", "https"} and parsed.hostname:
            origins.add(_normalized_origin(parsed))
    if not origins:
        raise OnlyOfficeSecurityError("未配置 ONLYOFFICE 安全下载来源。")
    return origins


def _normalized_origin(parsed: SplitResult) -> str:
    hostname = str(parsed.hostname or "").encode("idna").decode("ascii").lower()
    port = parsed.port or _default_port(parsed.scheme)
    return f"{parsed.scheme.lower()}://{hostname}:{port}"


def _host_header(parsed: SplitResult) -> str:
    hostname = str(parsed.hostname or "")
    port = parsed.port or _default_port(parsed.scheme)
    if port == _default_port(parsed.scheme):
        return hostname
    return f"{hostname}:{port}"


def _validate_download_mime(content_type: str, expected_filename: str) -> None:
    extension = expected_filename.rsplit(".", maxsplit=1)[-1].lower()
    allowed = {_OOXML_MIME_TYPES.get(extension, ""), *_GENERIC_BINARY_MIME_TYPES}
    if content_type not in allowed:
        raise OnlyOfficeSecurityError("ONLYOFFICE 返回的文档 MIME 类型不合法。")


def _validate_ooxml_content(content: bytes, expected_filename: str, *, max_bytes: int) -> None:
    extension = expected_filename.rsplit(".", maxsplit=1)[-1].lower()
    required_prefix = _OOXML_REQUIRED_ENTRY.get(extension)
    if required_prefix is None:
        raise OnlyOfficeSecurityError("ONLYOFFICE 回写文件格式不受支持。")
    try:
        with zipfile.ZipFile(BytesIO(content)) as archive:
            members = archive.infolist()
            if len(members) > 5000:
                raise OnlyOfficeSecurityError("ONLYOFFICE 文档包含过多压缩条目。")
            total_uncompressed = sum(int(member.file_size or 0) for member in members)
            if total_uncompressed > min(max_bytes * 20, 300 * 1024 * 1024):
                raise OnlyOfficeSecurityError("ONLYOFFICE 文档解压体积超过限制。")
            names = [member.filename.replace("\\", "/") for member in members]
            if any(
                name.startswith("/") or ".." in name.split("/") or member.flag_bits & 0x1
                for name, member in zip(names, members, strict=True)
            ):
                raise OnlyOfficeSecurityError("ONLYOFFICE 文档压缩结构不安全。")
            if "[Content_Types].xml" not in names or not any(
                name.startswith(required_prefix) for name in names
            ):
                raise OnlyOfficeSecurityError("ONLYOFFICE 文档内容与扩展名不匹配。")
    except (zipfile.BadZipFile, RuntimeError) as exc:
        raise OnlyOfficeSecurityError("ONLYOFFICE 返回的文档不是有效 OOXML 文件。") from exc


def _content_token_ttl_seconds() -> int:
    configured = _safe_int(os.environ.get("ONLYOFFICE_CONTENT_TOKEN_TTL_SECONDS"))
    return min(
        900,
        max(60, configured or DEFAULT_CONTENT_TOKEN_TTL_SECONDS),
    )


def _callback_session_ttl_seconds() -> int:
    configured = _safe_int(
        os.environ.get("ONLYOFFICE_CALLBACK_SESSION_TTL_SECONDS")
        or os.environ.get("ONLYOFFICE_SESSION_TTL_SECONDS")
    )
    return min(
        MAX_CALLBACK_SESSION_TTL_SECONDS,
        max(15 * 60, configured or DEFAULT_CALLBACK_SESSION_TTL_SECONDS),
    )


def _max_download_bytes() -> int:
    configured = _safe_int(os.environ.get("ONLYOFFICE_MAX_DOWNLOAD_BYTES"))
    return min(
        MAX_DOWNLOAD_BYTES_CEILING,
        max(1024, configured or DEFAULT_MAX_DOWNLOAD_BYTES),
    )


def _safe_int(value: object) -> int | None:
    try:
        return int(str(value).strip())
    except (TypeError, ValueError):
        return None


def _default_port(scheme: str) -> int:
    return 443 if scheme == "https" else 80


def _as_utc(value: datetime) -> datetime:
    return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)


def _build_document_key(entry: dict[str, Any]) -> str:
    version = int(entry.get("version_number") or 1)
    checksum = str(entry.get("sha256") or "")[:12]
    return f"{entry['id']}-v{version}-{checksum or 'nochecksum'}"
