from __future__ import annotations

import base64
import hashlib
import json
import mimetypes
import os
import platform
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Callable


class AgentgramError(RuntimeError):
    pass


class AgentgramClient:
    def __init__(
        self,
        token: str,
        base_url: str = "http://localhost:8787",
        cursor_path: str | os.PathLike[str] | None = None,
    ):
        if not token:
            raise ValueError("agent token is required")
        self.token = token
        self.base_url = base_url.rstrip("/")
        self.cursor_path = Path(cursor_path) if cursor_path else None
        self.cursor: str | None = self._load_cursor()

    @classmethod
    def register(
        cls,
        *,
        name: str,
        base_url: str,
        hardware: dict[str, Any] | None = None,
        enrollment_secret: str | None = None,
        cursor_path: str | os.PathLike[str] | None = None,
    ) -> tuple["AgentgramClient", dict[str, Any]]:
        base_url = base_url.rstrip("/")
        body = {"name": name, "hardware": hardware or hardware_fingerprint()}
        headers = {"content-type": "application/json", "accept": "application/json"}
        if enrollment_secret:
            headers["x-agentgram-enrollment"] = enrollment_secret
        request = urllib.request.Request(
            f"{base_url}/api/agents/register",
            data=json.dumps(body).encode("utf-8"),
            method="POST",
            headers=headers,
        )
        try:
            with urllib.request.urlopen(request, timeout=35) as response:
                payload = json.loads(response.read().decode("utf-8"))
        except urllib.error.HTTPError as exc:
            detail = exc.read().decode("utf-8", errors="replace")
            raise AgentgramError(f"register failed: HTTP {exc.code} {detail}") from exc
        return cls(token=payload["token"], base_url=base_url, cursor_path=cursor_path), payload

    def updates(self, timeout: int = 25_000) -> list[dict[str, Any]]:
        params = {"timeout": str(timeout)}
        if self.cursor:
            params["since"] = self.cursor
        payload = self._request("GET", f"/api/agent/updates?{urllib.parse.urlencode(params)}")
        self.cursor = str(payload.get("cursor") or payload.get("serverTime") or self.cursor or "0")
        self._save_cursor()
        return list(payload.get("messages") or [])

    def send(
        self,
        conversation_id: str,
        *,
        kind: str = "text",
        text: str = "",
        data: Any = None,
        attachments: list[dict[str, Any]] | None = None,
        meta: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        return self._request(
            "POST",
            "/api/agent/reply",
            {
                "conversationId": conversation_id,
                "kind": kind,
                "text": text,
                "data": data,
                "attachments": attachments or [],
                "meta": meta or {},
            },
        )

    def reply(self, conversation_id: str, text: str, meta: dict[str, Any] | None = None) -> dict[str, Any]:
        return self.send(conversation_id, kind="text", text=text, meta=meta)

    def send_file(
        self,
        conversation_id: str,
        path: str | os.PathLike[str],
        *,
        kind: str = "file",
        text: str = "",
        mime_type: str | None = None,
        meta: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        file_path = Path(path)
        raw = file_path.read_bytes()
        guessed_mime = mime_type or mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
        attachment = {
            "name": file_path.name,
            "mimeType": guessed_mime,
            "size": len(raw),
            "data": base64.b64encode(raw).decode("ascii"),
        }
        return self.send(conversation_id, kind=kind, text=text or file_path.name, attachments=[attachment], meta=meta)

    def run(
        self,
        on_message: Callable[[dict[str, Any], "AgentgramClient"], Any],
        *,
        timeout: int = 25_000,
        retry_delay: float = 1.0,
    ) -> None:
        while True:
            try:
                for message in self.updates(timeout=timeout):
                    if message.get("sender") == "user":
                        on_message(message, self)
            except KeyboardInterrupt:
                raise
            except Exception as exc:
                print(f"Agentgram polling error: {exc}")
                time.sleep(retry_delay)

    def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
        data = None if body is None else json.dumps(body).encode("utf-8")
        request = urllib.request.Request(
            self.base_url + path,
            data=data,
            method=method,
            headers={
                "authorization": f"Bearer {self.token}",
                "accept": "application/json",
                "content-type": "application/json",
            },
        )
        try:
            with urllib.request.urlopen(request, timeout=35) as response:
                return json.loads(response.read().decode("utf-8"))
        except urllib.error.HTTPError as exc:
            detail = exc.read().decode("utf-8", errors="replace")
            raise AgentgramError(f"{method} {path} failed: HTTP {exc.code} {detail}") from exc

    def _load_cursor(self) -> str | None:
        if not self.cursor_path or not self.cursor_path.exists():
            return None
        value = self.cursor_path.read_text().strip()
        return value or None

    def _save_cursor(self) -> None:
        if not self.cursor_path or self.cursor is None:
            return
        self.cursor_path.parent.mkdir(parents=True, exist_ok=True)
        tmp_path = self.cursor_path.with_suffix(self.cursor_path.suffix + ".tmp")
        tmp_path.write_text(self.cursor)
        tmp_path.replace(self.cursor_path)


def hardware_fingerprint(data_dir: str | os.PathLike[str] | None = None) -> dict[str, Any]:
    root = Path(data_dir or os.environ.get("AGENTGRAM_DATA_DIR", "/data"))
    root.mkdir(parents=True, exist_ok=True)
    persisted = root / "hardware-id"
    if persisted.exists():
        installation_id = persisted.read_text().strip()
    else:
        seed = f"{platform.node()}|{platform.machine()}|{platform.platform()}|{time.time_ns()}"
        installation_id = hashlib.sha256(seed.encode("utf-8")).hexdigest()
        persisted.write_text(installation_id)
    return {
        "installationId": installation_id,
        "node": platform.node(),
        "machine": platform.machine(),
        "system": platform.system(),
        "platform": platform.platform(),
        "agentRuntime": "python",
    }


def decode_attachment(attachment: dict[str, Any]) -> bytes:
    encoded = attachment.get("data")
    if not isinstance(encoded, str) or not encoded:
        raise AgentgramError("attachment does not contain inline base64 data")
    return base64.b64decode(encoded)


def save_attachment(attachment: dict[str, Any], directory: str | os.PathLike[str]) -> Path:
    root = Path(directory)
    root.mkdir(parents=True, exist_ok=True)
    name = safe_filename(str(attachment.get("name") or attachment.get("id") or "attachment"))
    path = unique_path(root / name)
    path.write_bytes(decode_attachment(attachment))
    return path


def save_attachments(message: dict[str, Any], directory: str | os.PathLike[str]) -> list[Path]:
    saved: list[Path] = []
    for attachment in message.get("attachments") or []:
        if isinstance(attachment, dict) and attachment.get("data"):
            saved.append(save_attachment(attachment, directory))
    return saved


def safe_filename(value: str) -> str:
    name = re.sub(r"[^A-Za-z0-9._ -]+", "_", value).strip(" .")
    return name or "attachment"


def unique_path(path: Path) -> Path:
    if not path.exists():
        return path
    stem = path.stem
    suffix = path.suffix
    for index in range(1, 10_000):
        candidate = path.with_name(f"{stem}-{index}{suffix}")
        if not candidate.exists():
            return candidate
    raise AgentgramError(f"could not create unique file path for {path}")
