#
#  Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
#
#  Licensed under the Apache License, Version 2.0 (the "License");
#  you may not use this file except in compliance with the License.
#  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.
#

from __future__ import annotations

import base64
import json
import logging
import mimetypes
import os
import posixpath
import stat
import time
import uuid
from typing import Any, Dict, List, Optional

from agent.sandbox.result_protocol import (
    build_javascript_wrapper,
    build_python_wrapper,
    extract_structured_result,
)
from .base import (
    ExecutionResult,
    SandboxInstance,
    SandboxProvider,
    SandboxProviderConfigError,
)

logger = logging.getLogger(__name__)

ALLOWED_ARTIFACT_EXTENSIONS = {
    ".csv",
    ".html",
    ".jpeg",
    ".jpg",
    ".json",
    ".pdf",
    ".png",
    ".svg",
}

# Base directory inside the Tenki sandbox. The default image runs as the
# unprivileged "tenki" user whose home is writable.
SANDBOX_HOME = "/home/tenki"

# Maximum directory depth walked when collecting artifacts, guarding against
# deeply nested or symlink-looped trees produced by untrusted code.
MAX_ARTIFACT_DEPTH = 16


class TenkiProvider(SandboxProvider):
    """Execute code in a disposable Tenki microVM.

    Each create_instance() provisions a fresh sandbox, execute_code() runs a
    single script in it, and destroy_instance() terminates it. Only the stable
    create/exec/destroy path is used; no volumes or snapshots.
    """

    def __init__(self):
        self.api_key = ""
        self.project_id = ""
        self.base_url = ""
        self.image = ""
        self.allow_outbound = False
        self.timeout = 30
        self.max_lifetime = 3600
        self.cpu_cores = 0
        self.memory_mb = 0
        self.disk_size_gb = 0
        self.max_output_bytes = 1024 * 1024
        self.max_artifacts = 20
        self.max_artifact_bytes = 10 * 1024 * 1024
        self._initialized = False
        self._client = None
        self._instances: dict[str, dict[str, Any]] = {}

    def initialize(self, config: Dict[str, Any]) -> bool:
        self.api_key = str(config.get("api_key", "") or "").strip()
        self.project_id = str(config.get("project_id", "") or "").strip()
        self.base_url = str(config.get("base_url", "") or "").strip()
        self.image = str(config.get("image", "") or "").strip()
        self.allow_outbound = bool(config.get("allow_outbound", False))
        self.timeout = int(config.get("timeout", 30) or 30)
        self.max_lifetime = int(config.get("max_lifetime", 3600) or 3600)
        self.cpu_cores = int(config.get("cpu_cores", 0) or 0)
        self.memory_mb = int(config.get("memory_mb", 0) or 0)
        self.disk_size_gb = int(config.get("disk_size_gb", 0) or 0)
        self.max_output_bytes = int(config.get("max_output_bytes", 1024 * 1024) or 1024 * 1024)
        self.max_artifacts = int(config.get("max_artifacts", 20) or 20)
        self.max_artifact_bytes = int(config.get("max_artifact_bytes", 10 * 1024 * 1024) or 10 * 1024 * 1024)

        is_valid, error_message = self.validate_config(
            {
                "api_key": self.api_key,
                "project_id": self.project_id,
                "timeout": self.timeout,
                "max_lifetime": self.max_lifetime,
                "max_output_bytes": self.max_output_bytes,
                "max_artifacts": self.max_artifacts,
                "max_artifact_bytes": self.max_artifact_bytes,
            }
        )
        if not is_valid:
            raise SandboxProviderConfigError(error_message or "Invalid Tenki provider configuration.")

        self._client = self._create_client()
        self._assert_connectivity()

        self._initialized = True
        logger.info("Tenki provider initialized")
        return True

    def create_instance(self, template: str = "python") -> SandboxInstance:
        if not self._initialized:
            raise RuntimeError("Provider not initialized. Call initialize() first.")

        language = self._normalize_language(template)
        errors = self._tenki_errors()

        create_kwargs: dict[str, Any] = {
            "project_id": self.project_id,
            "allow_outbound": self.allow_outbound,
            "max_duration": self.max_lifetime,
            "metadata": {"source": "ragflow"},
        }
        if self.image:
            create_kwargs["image"] = self.image
        if self.cpu_cores > 0:
            create_kwargs["cpu_cores"] = self.cpu_cores
        if self.memory_mb > 0:
            create_kwargs["memory_mb"] = self.memory_mb
        if self.disk_size_gb > 0:
            create_kwargs["disk_size_gb"] = self.disk_size_gb

        try:
            sandbox = self._client.create(**create_kwargs)
        except errors.QuotaExceededError as exc:
            raise RuntimeError(f"Tenki quota exceeded: {exc}") from exc
        except errors.RateLimitedError as exc:
            raise RuntimeError(f"Tenki rate limited, please retry: {exc}") from exc
        except errors.UnauthorizedError as exc:
            raise SandboxProviderConfigError("Tenki authentication failed: check the API key.") from exc
        except Exception as exc:
            # Satisfy the base contract: any other SDK failure becomes RuntimeError.
            raise RuntimeError(f"Failed to create Tenki sandbox: {exc}") from exc

        remote_work_dir = posixpath.join(SANDBOX_HOME, f"ragflow-codeexec-{uuid.uuid4().hex}")
        try:
            result = sandbox.exec(
                "mkdir",
                "-p",
                posixpath.join(remote_work_dir, "artifacts"),
                timeout=min(self.timeout, 10),
            )
            if result.exit_code != 0:
                raise RuntimeError(f"Failed to create sandbox workspace: {result.stderr_text or 'unknown error'}")
        except Exception:
            self._safe_terminate(sandbox)
            raise

        instance_id = str(uuid.uuid4())
        self._instances[instance_id] = {
            "sandbox": sandbox,
            "remote_work_dir": remote_work_dir,
            "language": language,
        }
        logger.info("Tenki sandbox created (instance=%s, session=%s)", instance_id, sandbox.id)

        return SandboxInstance(
            instance_id=instance_id,
            provider="tenki",
            status="running",
            metadata={"language": language, "remote_work_dir": remote_work_dir, "sandbox_id": sandbox.id},
        )

    def execute_code(
        self,
        instance_id: str,
        code: str,
        language: str,
        timeout: int = 10,
        arguments: Optional[Dict[str, Any]] = None,
    ) -> ExecutionResult:
        if not self._initialized:
            raise RuntimeError("Provider not initialized. Call initialize() first.")
        if instance_id not in self._instances:
            raise RuntimeError(f"Unknown Tenki sandbox instance: {instance_id}")

        normalized_lang = self._normalize_language(language)
        instance = self._instances[instance_id]
        sandbox = instance["sandbox"]
        remote_work_dir: str = instance["remote_work_dir"]
        errors = self._tenki_errors()

        args_json = json.dumps(arguments or {}, ensure_ascii=False)
        script_path, argv = self._prepare_script(sandbox, remote_work_dir, normalized_lang, code, args_json)

        requested_timeout = self.timeout if timeout is None else int(timeout)
        if requested_timeout <= 0:
            raise RuntimeError(f"Execution timeout must be greater than 0 seconds, got {requested_timeout}.")
        exec_timeout = min(requested_timeout, self.timeout)

        start_time = time.time()
        try:
            result = sandbox.exec(*argv, cwd=remote_work_dir, timeout=exec_timeout)
        except (errors.CommandTimeoutError, errors.PrimitiveTimeoutError) as exc:
            logger.warning("Tenki execution timed out (instance=%s, timeout=%ss)", instance_id, exec_timeout)
            raise TimeoutError(f"Execution timed out after {exec_timeout} seconds") from exc
        except (errors.SessionTerminatedError, errors.SessionNotFoundError) as exc:
            # The sandbox was reclaimed (e.g. max_lifetime) or lost mid-run;
            # surface it as RuntimeError per the base contract, not an SDK type.
            raise RuntimeError(f"Tenki sandbox is no longer available: {exc}") from exc
        except Exception as exc:
            raise RuntimeError(f"Tenki execution failed: {exc}") from exc
        execution_time = time.time() - start_time

        stdout = result.stdout_text
        stderr = result.stderr_text
        exit_code = int(result.exit_code)

        self._validate_output_size(stdout, stderr)
        stdout, structured_result = extract_structured_result(stdout)

        return ExecutionResult(
            stdout=stdout,
            stderr=stderr,
            exit_code=exit_code,
            execution_time=execution_time,
            metadata={
                "instance_id": instance_id,
                "language": normalized_lang,
                "script_path": script_path,
                "remote_work_dir": remote_work_dir,
                "status": "ok" if exit_code == 0 else "error",
                "timeout": exec_timeout,
                "artifacts": self._collect_artifacts(sandbox, posixpath.join(remote_work_dir, "artifacts")),
                "result_present": structured_result.get("present", False),
                "result_value": structured_result.get("value"),
                "result_type": structured_result.get("type"),
            },
        )

    def destroy_instance(self, instance_id: str) -> bool:
        if not self._initialized:
            raise RuntimeError("Provider not initialized. Call initialize() first.")
        if instance_id not in self._instances:
            return True

        instance = self._instances.pop(instance_id)
        self._safe_terminate(instance["sandbox"])
        logger.info("Tenki sandbox destroyed (instance=%s)", instance_id)
        return True

    def health_check(self) -> bool:
        try:
            self._assert_connectivity()
            return True
        except Exception:
            return False

    def get_supported_languages(self) -> List[str]:
        return ["python", "javascript"]

    @staticmethod
    def get_config_schema() -> Dict[str, Dict]:
        return {
            "api_key": {
                "type": "string",
                "required": True,
                "label": "API Key",
                "secret": True,
                "placeholder": "tk_...",
                "description": "Tenki API key. Create one at https://app.tenki.cloud under API Keys.",
            },
            "project_id": {
                "type": "string",
                "required": True,
                "label": "Project ID",
                "placeholder": "Tenki project UUID",
                "description": "Tenki project that sandboxes are created under.",
            },
            "base_url": {
                "type": "string",
                "required": False,
                "label": "API Endpoint",
                "placeholder": "https://api.tenki.cloud",
                "description": "Override the Tenki API endpoint. Leave empty for the default.",
            },
            "image": {
                "type": "string",
                "required": False,
                "label": "Sandbox Image",
                "description": "Base image for sandboxes. Empty uses the Tenki default image (includes python3 and node).",
            },
            "allow_outbound": {
                "type": "boolean",
                "required": False,
                "label": "Allow Outbound Network",
                "default": False,
                "description": "Security-relevant. Disabled by default so sandboxed code has no network access. Enable it to let code make outbound connections (e.g. to install packages).",
            },
            "timeout": {
                "type": "integer",
                "required": False,
                "label": "Timeout (seconds)",
                "default": 30,
                "min": 1,
                "max": 600,
                "description": "Maximum execution time for a single run.",
            },
            "max_lifetime": {
                "type": "integer",
                "required": False,
                "label": "Max Sandbox Lifetime (seconds)",
                "default": 3600,
                "min": 60,
                "max": 86400,
                "description": "Tenki reclaims a sandbox after this, guarding against leaks if the run is interrupted.",
            },
            "cpu_cores": {
                "type": "integer",
                "required": False,
                "label": "vCPU Cores",
                "default": 0,
                "min": 0,
                "max": 16,
                "description": "vCPU per sandbox. 0 uses the Tenki default.",
            },
            "memory_mb": {
                "type": "integer",
                "required": False,
                "label": "Memory (MB)",
                "default": 0,
                "min": 0,
                "max": 65536,
                "description": "Memory per sandbox in MB. 0 uses the Tenki default.",
            },
            "disk_size_gb": {
                "type": "integer",
                "required": False,
                "label": "Disk Size (GB)",
                "default": 0,
                "min": 0,
                "max": 100,
                "description": "Disk size per sandbox in GB. 0 uses the Tenki default.",
            },
            "max_output_bytes": {
                "type": "integer",
                "required": False,
                "label": "Max Output Bytes",
                "default": 1048576,
                "min": 1024,
                "max": 10485760,
                "description": "Maximum combined stdout and stderr size.",
            },
            "max_artifacts": {
                "type": "integer",
                "required": False,
                "label": "Max Artifacts",
                "default": 20,
                "min": 0,
                "max": 100,
                "description": "Maximum number of files collected from the sandbox artifacts directory.",
            },
            "max_artifact_bytes": {
                "type": "integer",
                "required": False,
                "label": "Max Artifact Bytes",
                "default": 10485760,
                "min": 1024,
                "max": 104857600,
                "description": "Maximum size of a single artifact file in bytes.",
            },
        }

    def validate_config(self, config: Dict[str, Any]) -> tuple[bool, Optional[str]]:
        api_key = str(config.get("api_key", "") or "").strip()
        project_id = str(config.get("project_id", "") or "").strip()

        if not api_key:
            return False, "Tenki API key is required"
        if not project_id:
            return False, "Tenki project_id is required"

        for key in ("timeout", "max_lifetime", "max_output_bytes", "max_artifacts", "max_artifact_bytes"):
            try:
                value = int(config.get(key, 0) or 0)
            except (TypeError, ValueError):
                return False, f"{key} must be an integer"
            if key == "max_artifacts":
                if value < 0:
                    return False, "max_artifacts must be greater than or equal to 0"
            elif value <= 0:
                return False, f"{key} must be greater than 0"

        return True, None

    # -- internal helpers -------------------------------------------------

    def _create_client(self):
        tenki = _get_tenki_module()
        # Bound control-plane requests (who_am_i, create) so a slow or
        # unreachable API cannot block initialize()/create_instance() forever.
        kwargs: dict[str, Any] = {"auth_token": self.api_key, "timeout": float(self.timeout)}
        if self.base_url:
            kwargs["base_url"] = self.base_url
        return tenki.Client(**kwargs)

    def _assert_connectivity(self) -> None:
        client = self._client or self._create_client()
        errors = self._tenki_errors()
        try:
            client.who_am_i()
        except errors.UnauthorizedError as exc:
            raise SandboxProviderConfigError("Tenki authentication failed: check the API key.") from exc
        except Exception as exc:
            raise SandboxProviderConfigError(f"Failed to reach Tenki API: {exc}") from exc

    def _prepare_script(self, sandbox, remote_work_dir: str, language: str, code: str, args_json: str) -> tuple[str, list[str]]:
        if language == "python":
            script_name = "main.py"
            script_content = build_python_wrapper(code, args_json)
            executable = "python3"
        elif language in {"javascript", "nodejs"}:
            script_name = "main.js"
            script_content = build_javascript_wrapper(code, args_json)
            executable = "node"
        else:
            raise RuntimeError(f"Unsupported language for Tenki provider: {language}")

        script_path = posixpath.join(remote_work_dir, script_name)
        sandbox.fs.write_text(script_path, script_content)
        return script_path, [executable, script_path]

    def _validate_output_size(self, stdout: str, stderr: str) -> None:
        output_size = len((stdout or "").encode("utf-8")) + len((stderr or "").encode("utf-8"))
        if output_size > self.max_output_bytes:
            raise RuntimeError(f"Tenki execution output exceeded {self.max_output_bytes} bytes.")

    def _collect_artifacts(self, sandbox, artifacts_dir: str) -> list[dict[str, Any]]:
        artifacts: list[dict[str, Any]] = []
        self._collect_artifacts_recursive(sandbox, artifacts_dir, "", artifacts, depth=0)
        return artifacts

    def _collect_artifacts_recursive(self, sandbox, current_dir: str, relative_dir: str, artifacts: list[dict[str, Any]], depth: int) -> None:
        if depth > MAX_ARTIFACT_DEPTH:
            raise RuntimeError(f"Artifact directory nesting exceeds {MAX_ARTIFACT_DEPTH} levels: {relative_dir}")

        errors = self._tenki_errors()
        try:
            entries = sandbox.fs.list(current_dir)
        except errors.FileNotFoundError:
            return
        except FileNotFoundError:
            return

        # fs.list returns each entry's basename in `.path`, not an absolute
        # path, so join it onto the directory being listed.
        for entry in sorted(entries, key=lambda item: item.path):
            name = posixpath.basename(entry.path)
            remote_path = posixpath.join(current_dir, name)
            relative_path = posixpath.join(relative_dir, name) if relative_dir else name

            # Reject symlinks. `is_symlink` is not populated by every SDK
            # release, so also inspect the stat mode bits as the reliable check.
            if getattr(entry, "is_symlink", False) or stat.S_ISLNK(entry.mode or 0):
                raise RuntimeError(f"Artifact symlinks are not allowed: {relative_path}")
            if entry.is_dir:
                self._collect_artifacts_recursive(sandbox, remote_path, relative_path, artifacts, depth + 1)
                continue

            if len(artifacts) >= self.max_artifacts:
                raise RuntimeError(f"Tenki execution produced more than {self.max_artifacts} artifacts.")

            size = int(entry.size or 0)
            if size > self.max_artifact_bytes:
                raise RuntimeError(f"Artifact exceeds {self.max_artifact_bytes} bytes: {relative_path}")

            ext = os.path.splitext(name)[1].lower()
            if ext not in ALLOWED_ARTIFACT_EXTENSIONS:
                raise RuntimeError(f"Unsupported artifact type: {relative_path}")

            content = sandbox.fs.read_bytes(remote_path)
            artifacts.append(
                {
                    "name": relative_path,
                    "content_b64": base64.b64encode(content).decode("ascii"),
                    "mime_type": mimetypes.guess_type(name)[0] or "application/octet-stream",
                    "size": size,
                }
            )

    def _safe_terminate(self, sandbox) -> None:
        # Best-effort: max_lifetime reclaims the sandbox if this fails.
        try:
            sandbox.terminate()
        except Exception as exc:
            logger.warning("Failed to terminate Tenki sandbox, relying on max_lifetime: %s", exc)

    def _tenki_errors(self):
        tenki = _get_tenki_module()
        return tenki.errors

    @staticmethod
    def _normalize_language(language: str) -> str:
        lang_lower = (language or "python").lower()
        if lang_lower in {"python", "python3"}:
            return "python"
        if lang_lower in {"javascript", "nodejs"}:
            return "nodejs"
        return lang_lower


def _get_tenki_module():
    try:
        import tenki_sandbox
    except ImportError as exc:
        # tenki-sandbox is an optional dependency: it requires protobuf>=6.31,
        # which conflicts with RAGFlow's pinned gRPC stack, so it is not a core
        # dependency. Install it into the runtime to enable this provider.
        raise SandboxProviderConfigError("tenki-sandbox is required for the Tenki sandbox provider. Install it with `pip install tenki-sandbox` (or `uv pip install tenki-sandbox`).") from exc
    return tenki_sandbox
