"""Workspace-scoped artifact sandbox for file-based agent outputs."""
from __future__ import annotations

import base64
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
from typing import Any
from uuid import uuid4
from xml.sax.saxutils import escape as xml_escape
from zipfile import ZIP_DEFLATED, ZipFile

from logos_adk.context import get_run_context


_DEFAULT_ARTIFACTS_DIR = ".logos_adk/artifacts"
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
_WORKSPACE_SAFE_RE = re.compile(r"[^A-Za-z0-9._-]+")


@dataclass(frozen=True)
class ArtifactRecord:
    """Persisted artifact metadata."""

    id: str
    workspace_id: str
    name: str
    kind: str
    mime_type: str
    path: str
    created_at: str
    size_bytes: int
    sha256: str
    created_by: str | None = None
    metadata: dict[str, Any] = field(default_factory=dict)

    def to_ref(self) -> dict[str, Any]:
        """Return a JSON-safe reference that can be posted to blackboards or event payloads."""
        payload = asdict(self)
        payload["download_path"] = f"/artifacts/{self.id}/content"
        return payload


class ArtifactSandbox:
    """Filesystem-backed sandbox for shared digital artifacts."""

    def __init__(self, root: str | os.PathLike[str] | None = None) -> None:
        configured_root = root or os.getenv("LOGOS_ADK_ARTIFACTS_DIR") or _DEFAULT_ARTIFACTS_DIR
        root_path = Path(configured_root)
        if not root_path.is_absolute():
            root_path = Path.cwd() / root_path
        self.root = root_path

    def create_text_artifact(
        self,
        *,
        workspace_id: str | None,
        name: str,
        content: str,
        created_by: str | None = None,
        mime_type: str = "text/plain; charset=utf-8",
        metadata: dict[str, Any] | None = None,
        extension: str = ".txt",
    ) -> ArtifactRecord:
        if not isinstance(content, str):
            raise ValueError("content must be a string")
        return self._persist_artifact(
            workspace_id=workspace_id,
            name=name,
            kind="text",
            mime_type=mime_type,
            data=content.encode("utf-8"),
            created_by=created_by,
            metadata=metadata,
            extension=extension,
        )

    def create_json_artifact(
        self,
        *,
        workspace_id: str | None,
        name: str,
        payload: Any,
        created_by: str | None = None,
        metadata: dict[str, Any] | None = None,
    ) -> ArtifactRecord:
        data = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8")
        return self._persist_artifact(
            workspace_id=workspace_id,
            name=name,
            kind="json",
            mime_type="application/json",
            data=data,
            created_by=created_by,
            metadata=metadata,
            extension=".json",
        )

    def create_binary_artifact(
        self,
        *,
        workspace_id: str | None,
        name: str,
        data: bytes,
        mime_type: str,
        kind: str = "binary",
        created_by: str | None = None,
        metadata: dict[str, Any] | None = None,
        extension: str | None = None,
    ) -> ArtifactRecord:
        if not isinstance(data, (bytes, bytearray)):
            raise ValueError("data must be bytes")
        return self._persist_artifact(
            workspace_id=workspace_id,
            name=name,
            kind=kind,
            mime_type=mime_type,
            data=bytes(data),
            created_by=created_by,
            metadata=metadata,
            extension=extension,
        )

    def create_excel_artifact(
        self,
        *,
        workspace_id: str | None,
        name: str,
        rows: list[dict[str, Any]],
        columns: list[str] | None = None,
        sheet_name: str = "Sheet1",
        created_by: str | None = None,
        metadata: dict[str, Any] | None = None,
    ) -> ArtifactRecord:
        if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows):
            raise ValueError("rows must be a list of dict objects")
        ordered_columns = columns or self._derive_columns(rows)
        workbook_bytes = self._build_xlsx_bytes(rows, ordered_columns, sheet_name=sheet_name)
        payload_metadata = dict(metadata or {})
        payload_metadata.setdefault("columns", ordered_columns)
        payload_metadata.setdefault("row_count", len(rows))
        payload_metadata.setdefault("sheet_name", sheet_name)
        return self._persist_artifact(
            workspace_id=workspace_id,
            name=name,
            kind="excel",
            mime_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            data=workbook_bytes,
            created_by=created_by,
            metadata=payload_metadata,
            extension=".xlsx",
        )

    def create_svg_artifact(
        self,
        *,
        workspace_id: str | None,
        name: str,
        title: str,
        lines: list[str] | None = None,
        created_by: str | None = None,
        width: int = 1200,
        height: int = 628,
        background: str = "#F6EFE2",
        accent: str = "#0F172A",
        metadata: dict[str, Any] | None = None,
    ) -> ArtifactRecord:
        if not isinstance(title, str) or not title.strip():
            raise ValueError("title must be a non-empty string")
        rendered_lines = [str(line) for line in (lines or [])][:8]
        svg = self._build_svg_document(
            title=title.strip(),
            lines=rendered_lines,
            width=width,
            height=height,
            background=background,
            accent=accent,
        )
        payload_metadata = dict(metadata or {})
        payload_metadata.setdefault("width", width)
        payload_metadata.setdefault("height", height)
        return self._persist_artifact(
            workspace_id=workspace_id,
            name=name,
            kind="image",
            mime_type="image/svg+xml",
            data=svg.encode("utf-8"),
            created_by=created_by,
            metadata=payload_metadata,
            extension=".svg",
        )

    def create_video_artifact(
        self,
        *,
        workspace_id: str | None,
        name: str,
        artifact_ids: list[str] | None = None,
        image_paths: list[str] | None = None,
        frame_duration_seconds: float = 1.5,
        fps: int = 24,
        created_by: str | None = None,
        metadata: dict[str, Any] | None = None,
    ) -> ArtifactRecord:
        if bool(artifact_ids) == bool(image_paths):
            raise ValueError("Pass either artifact_ids or image_paths")
        resolved_workspace = self._normalize_workspace_id(workspace_id)
        frames = (
            self._resolve_artifact_frame_paths(artifact_ids or [], workspace_id=resolved_workspace)
            if artifact_ids is not None
            else self._resolve_frame_paths(image_paths or [], workspace_id=resolved_workspace)
        )
        ffmpeg_path = shutil.which("ffmpeg")
        if ffmpeg_path is None:
            raise RuntimeError("ffmpeg is required to render video artifacts")
        self._ensure_directories()
        artifact_id = uuid4().hex
        output_path = self._workspace_files_dir(resolved_workspace) / f"{artifact_id}-{self._safe_name(name, default='artifact')}.mp4"
        concat_manifest = self._workspace_tmp_dir(resolved_workspace) / f"{artifact_id}-concat.txt"
        concat_manifest.write_text(self._build_concat_manifest(frames, frame_duration_seconds), encoding="utf-8")
        command = [
            ffmpeg_path,
            "-y",
            "-f",
            "concat",
            "-safe",
            "0",
            "-i",
            str(concat_manifest),
            "-vf",
            "format=yuv420p",
            "-r",
            str(max(1, int(fps))),
            "-movflags",
            "+faststart",
            str(output_path),
        ]
        completed = subprocess.run(command, capture_output=True, text=True, check=False)
        if completed.returncode != 0 or not output_path.exists():
            details = completed.stderr.strip() or completed.stdout.strip() or "ffmpeg failed"
            raise RuntimeError(f"Video render failed: {details}")
        payload_metadata = dict(metadata or {})
        payload_metadata.setdefault("frame_count", len(frames))
        payload_metadata.setdefault("frame_duration_seconds", frame_duration_seconds)
        payload_metadata.setdefault("fps", fps)
        payload_metadata.setdefault("source_artifact_ids", artifact_ids or [])
        record = self._index_existing_file(
            workspace_id=resolved_workspace,
            artifact_id=artifact_id,
            file_path=output_path,
            name=name,
            kind="video",
            mime_type="video/mp4",
            created_by=created_by,
            metadata=payload_metadata,
        )
        concat_manifest.unlink(missing_ok=True)
        return record

    def create_base64_artifact(
        self,
        *,
        workspace_id: str | None,
        name: str,
        base64_data: str,
        mime_type: str,
        kind: str = "binary",
        created_by: str | None = None,
        metadata: dict[str, Any] | None = None,
        extension: str | None = None,
    ) -> ArtifactRecord:
        try:
            data = base64.b64decode(base64_data, validate=True)
        except Exception as exc:
            raise ValueError(f"Invalid base64 payload: {exc}") from exc
        return self.create_binary_artifact(
            workspace_id=workspace_id,
            name=name,
            data=data,
            mime_type=mime_type,
            kind=kind,
            created_by=created_by,
            metadata=metadata,
            extension=extension,
        )

    def get_artifact(self, artifact_id: str) -> ArtifactRecord | None:
        metadata_path = self._index_dir() / f"{artifact_id}.json"
        if not metadata_path.exists():
            return None
        payload = json.loads(metadata_path.read_text(encoding="utf-8"))
        return ArtifactRecord(
            id=payload["id"],
            workspace_id=payload["workspace_id"],
            name=payload["name"],
            kind=payload["kind"],
            mime_type=payload["mime_type"],
            path=payload["path"],
            created_at=payload["created_at"],
            size_bytes=payload["size_bytes"],
            sha256=payload["sha256"],
            created_by=payload.get("created_by"),
            metadata=payload.get("metadata") or {},
        )

    def list_artifacts(
        self,
        *,
        workspace_id: str | None = None,
        kind: str | None = None,
        limit: int = 100,
    ) -> list[ArtifactRecord]:
        self._ensure_directories()
        normalized_workspace = (
            self._normalize_workspace_id(workspace_id)
            if workspace_id is not None
            else None
        )
        records: list[ArtifactRecord] = []
        for metadata_path in sorted(self._index_dir().glob("*.json"), reverse=True):
            payload = json.loads(metadata_path.read_text(encoding="utf-8"))
            if normalized_workspace is not None and payload.get("workspace_id") != normalized_workspace:
                continue
            if kind is not None and payload.get("kind") != kind:
                continue
            records.append(
                ArtifactRecord(
                    id=payload["id"],
                    workspace_id=payload["workspace_id"],
                    name=payload["name"],
                    kind=payload["kind"],
                    mime_type=payload["mime_type"],
                    path=payload["path"],
                    created_at=payload["created_at"],
                    size_bytes=payload["size_bytes"],
                    sha256=payload["sha256"],
                    created_by=payload.get("created_by"),
                    metadata=payload.get("metadata") or {},
                )
            )
            if len(records) >= max(1, int(limit)):
                break
        records.sort(key=lambda item: (item.created_at, item.id), reverse=True)
        return records

    def artifact_path(self, artifact_id: str) -> Path | None:
        record = self.get_artifact(artifact_id)
        if record is None:
            return None
        path = Path(record.path)
        return path if path.exists() else None

    def read_text(self, artifact_id: str, *, encoding: str = "utf-8") -> str:
        path = self.artifact_path(artifact_id)
        if path is None:
            raise FileNotFoundError(f"Artifact '{artifact_id}' not found")
        return path.read_text(encoding=encoding)

    def current_workspace_id(self, explicit_workspace_id: str | None = None) -> str:
        return self._normalize_workspace_id(explicit_workspace_id)

    def _persist_artifact(
        self,
        *,
        workspace_id: str | None,
        name: str,
        kind: str,
        mime_type: str,
        data: bytes,
        created_by: str | None,
        metadata: dict[str, Any] | None,
        extension: str | None,
    ) -> ArtifactRecord:
        self._ensure_directories()
        resolved_workspace = self._normalize_workspace_id(workspace_id)
        artifact_id = uuid4().hex
        safe_name = self._safe_name(name, default="artifact")
        suffix = extension if extension else Path(safe_name).suffix
        filename = f"{artifact_id}-{safe_name}"
        if suffix and not filename.endswith(suffix):
            filename += suffix
        file_path = self._workspace_files_dir(resolved_workspace) / filename
        file_path.write_bytes(data)
        return self._index_existing_file(
            workspace_id=resolved_workspace,
            artifact_id=artifact_id,
            file_path=file_path,
            name=name,
            kind=kind,
            mime_type=mime_type,
            created_by=created_by,
            metadata=metadata or {},
        )

    def _index_existing_file(
        self,
        *,
        workspace_id: str,
        artifact_id: str,
        file_path: Path,
        name: str,
        kind: str,
        mime_type: str,
        created_by: str | None,
        metadata: dict[str, Any],
    ) -> ArtifactRecord:
        data = file_path.read_bytes()
        record = ArtifactRecord(
            id=artifact_id,
            workspace_id=workspace_id,
            name=name.strip() if isinstance(name, str) and name.strip() else file_path.name,
            kind=kind,
            mime_type=mime_type,
            path=str(file_path.resolve()),
            created_at=datetime.now(UTC).isoformat(),
            size_bytes=len(data),
            sha256=hashlib.sha256(data).hexdigest(),
            created_by=created_by,
            metadata=dict(metadata),
        )
        self._index_dir().mkdir(parents=True, exist_ok=True)
        (self._index_dir() / f"{artifact_id}.json").write_text(
            json.dumps(asdict(record), ensure_ascii=False, indent=2, sort_keys=True),
            encoding="utf-8",
        )
        return record

    def _ensure_directories(self) -> None:
        self.root.mkdir(parents=True, exist_ok=True)
        self._index_dir().mkdir(parents=True, exist_ok=True)
        (self.root / "workspaces").mkdir(parents=True, exist_ok=True)

    def _index_dir(self) -> Path:
        return self.root / "index"

    def _workspace_dir(self, workspace_id: str) -> Path:
        return self.root / "workspaces" / self._safe_workspace(workspace_id)

    def _workspace_files_dir(self, workspace_id: str) -> Path:
        path = self._workspace_dir(workspace_id) / "files"
        path.mkdir(parents=True, exist_ok=True)
        return path

    def _workspace_tmp_dir(self, workspace_id: str) -> Path:
        path = self._workspace_dir(workspace_id) / "tmp"
        path.mkdir(parents=True, exist_ok=True)
        return path

    def _normalize_workspace_id(self, workspace_id: str | None) -> str:
        if isinstance(workspace_id, str) and workspace_id.strip():
            return workspace_id.strip()
        run_context = get_run_context()
        if run_context is not None and isinstance(run_context.workspace_id, str) and run_context.workspace_id.strip():
            return run_context.workspace_id.strip()
        return "__global__"

    def _safe_workspace(self, workspace_id: str) -> str:
        cleaned = _WORKSPACE_SAFE_RE.sub("_", workspace_id.strip())
        return cleaned or "__global__"

    def _safe_name(self, value: str, *, default: str) -> str:
        if not isinstance(value, str):
            return default
        cleaned = _SAFE_NAME_RE.sub("_", value.strip())
        cleaned = cleaned.strip("._-")
        return cleaned or default

    def _derive_columns(self, rows: list[dict[str, Any]]) -> list[str]:
        columns: list[str] = []
        seen: set[str] = set()
        for row in rows:
            for key in row.keys():
                key_str = str(key)
                if key_str in seen:
                    continue
                seen.add(key_str)
                columns.append(key_str)
        return columns

    def _build_xlsx_bytes(self, rows: list[dict[str, Any]], columns: list[str], *, sheet_name: str) -> bytes:
        with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp_file:
            temp_path = Path(tmp_file.name)
        try:
            with ZipFile(temp_path, "w", compression=ZIP_DEFLATED) as archive:
                archive.writestr("[Content_Types].xml", self._xlsx_content_types_xml())
                archive.writestr("_rels/.rels", self._xlsx_root_rels_xml())
                archive.writestr("xl/workbook.xml", self._xlsx_workbook_xml(sheet_name=sheet_name))
                archive.writestr("xl/_rels/workbook.xml.rels", self._xlsx_workbook_rels_xml())
                archive.writestr("xl/styles.xml", self._xlsx_styles_xml())
                archive.writestr("xl/worksheets/sheet1.xml", self._xlsx_sheet_xml(rows, columns))
            return temp_path.read_bytes()
        finally:
            temp_path.unlink(missing_ok=True)

    def _xlsx_content_types_xml(self) -> str:
        return (
            '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
            '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
            '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
            '<Default Extension="xml" ContentType="application/xml"/>'
            '<Override PartName="/xl/workbook.xml" '
            'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
            '<Override PartName="/xl/worksheets/sheet1.xml" '
            'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
            '<Override PartName="/xl/styles.xml" '
            'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
            '</Types>'
        )

    def _xlsx_root_rels_xml(self) -> str:
        return (
            '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
            '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
            '<Relationship Id="rId1" '
            'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
            'Target="xl/workbook.xml"/>'
            '</Relationships>'
        )

    def _xlsx_workbook_xml(self, *, sheet_name: str) -> str:
        return (
            '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
            '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
            'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
            '<sheets>'
            f'<sheet name="{xml_escape(sheet_name[:31] or "Sheet1")}" sheetId="1" r:id="rId1"/>'
            '</sheets>'
            '</workbook>'
        )

    def _xlsx_workbook_rels_xml(self) -> str:
        return (
            '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
            '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
            '<Relationship Id="rId1" '
            'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
            'Target="worksheets/sheet1.xml"/>'
            '<Relationship Id="rId2" '
            'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" '
            'Target="styles.xml"/>'
            '</Relationships>'
        )

    def _xlsx_styles_xml(self) -> str:
        return (
            '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
            '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
            '<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
            '<fills count="1"><fill><patternFill patternType="none"/></fill></fills>'
            '<borders count="1"><border/></borders>'
            '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
            '<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>'
            '</styleSheet>'
        )

    def _xlsx_sheet_xml(self, rows: list[dict[str, Any]], columns: list[str]) -> str:
        xml_rows: list[str] = []
        header_cells = "".join(
            self._xlsx_cell_xml(row_index=1, column_index=index + 1, value=column, is_header=True)
            for index, column in enumerate(columns)
        )
        xml_rows.append(f'<row r="1">{header_cells}</row>')
        for row_index, row in enumerate(rows, start=2):
            cells = "".join(
                self._xlsx_cell_xml(row_index=row_index, column_index=index + 1, value=row.get(column))
                for index, column in enumerate(columns)
            )
            xml_rows.append(f'<row r="{row_index}">{cells}</row>')
        dimension_end = self._xlsx_column_letter(max(1, len(columns))) + str(max(1, len(rows) + 1))
        return (
            '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
            '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
            f'<dimension ref="A1:{dimension_end}"/>'
            '<sheetData>'
            + "".join(xml_rows)
            + '</sheetData></worksheet>'
        )

    def _xlsx_cell_xml(
        self,
        *,
        row_index: int,
        column_index: int,
        value: Any,
        is_header: bool = False,
    ) -> str:
        reference = f"{self._xlsx_column_letter(column_index)}{row_index}"
        if value is None:
            return f'<c r="{reference}"/>'
        if (isinstance(value, (int, float)) and not isinstance(value, bool)) and not is_header:
            return f'<c r="{reference}"><v>{value}</v></c>'
        text = xml_escape(str(value))
        return f'<c r="{reference}" t="inlineStr"><is><t>{text}</t></is></c>'

    def _xlsx_column_letter(self, index: int) -> str:
        result = ""
        value = max(1, index)
        while value > 0:
            value, remainder = divmod(value - 1, 26)
            result = chr(65 + remainder) + result
        return result

    def _build_svg_document(
        self,
        *,
        title: str,
        lines: list[str],
        width: int,
        height: int,
        background: str,
        accent: str,
    ) -> str:
        escaped_title = xml_escape(title)
        rendered_lines = "".join(
            f'<text x="96" y="{220 + index * 54}" fill="{accent}" font-size="34" font-family="Georgia, serif">{xml_escape(line)}</text>'
            for index, line in enumerate(lines)
        )
        return (
            f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">'
            f'<rect width="{width}" height="{height}" fill="{background}"/>'
            f'<rect x="64" y="64" width="{width - 128}" height="{height - 128}" rx="28" fill="#ffffff" opacity="0.72"/>'
            f'<circle cx="{width - 180}" cy="120" r="64" fill="{accent}" opacity="0.12"/>'
            f'<text x="96" y="150" fill="{accent}" font-size="62" font-family="Georgia, serif" font-weight="700">{escaped_title}</text>'
            f'{rendered_lines}'
            '</svg>'
        )

    def _resolve_artifact_frame_paths(self, artifact_ids: list[str], *, workspace_id: str) -> list[Path]:
        frames: list[Path] = []
        for artifact_id in artifact_ids:
            record = self.get_artifact(artifact_id)
            if record is None:
                raise FileNotFoundError(f"Artifact '{artifact_id}' not found")
            if record.workspace_id != workspace_id:
                raise ValueError(f"Artifact '{artifact_id}' is not in workspace '{workspace_id}'")
            path = Path(record.path)
            if not path.exists():
                raise FileNotFoundError(f"Artifact content missing for '{artifact_id}'")
            frames.append(path)
        if not frames:
            raise ValueError("At least one artifact frame is required")
        return frames

    def _resolve_frame_paths(self, image_paths: list[str], *, workspace_id: str) -> list[Path]:
        files_dir = self._workspace_files_dir(workspace_id).resolve()
        frames: list[Path] = []
        for raw_path in image_paths:
            path = Path(raw_path).resolve()
            if not str(path).startswith(str(files_dir)):
                raise ValueError("image_paths must point to files inside the artifact sandbox workspace")
            if not path.exists():
                raise FileNotFoundError(f"Image frame '{path}' not found")
            frames.append(path)
        if not frames:
            raise ValueError("At least one image path is required")
        return frames

    def _build_concat_manifest(self, frames: list[Path], frame_duration_seconds: float) -> str:
        duration = max(0.04, float(frame_duration_seconds))
        lines: list[str] = []
        for frame in frames:
            escaped_path = str(frame).replace("'", "'\\''")
            lines.append(f"file '{escaped_path}'")
            lines.append(f"duration {duration}")
        escaped_last_path = str(frames[-1]).replace("'", "'\\''")
        lines.append(f"file '{escaped_last_path}'")
        return "\n".join(lines) + "\n"


artifact_sandbox = ArtifactSandbox()


__all__ = [
    "ArtifactRecord",
    "ArtifactSandbox",
    "artifact_sandbox",
]
