# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Tomasz Głuc
"""OmniDAW API client for Python (standard library only).

    from omnidaw import Omni
    omni = Omni()                      # reads the endpoint file, says hello
    print(omni.session.info())
    tid = omni.tracks.create(kind="midi", instrument="builtin://omni-synth")["id"]
    job = omni.analysis.capture(source="master", seconds=4)["job"]
    print(omni.wait(job)["loudness"])

Every method of the running Omni is an attribute, `domain.method(**params)`,
built from `api.methods` at connect time, so a newer Omni brings its new
methods without a new client. `omni.methods` holds the catalog (schemas,
examples, effects), `omni.capabilities` what the host offers. A method named
like a Python keyword takes a trailing underscore: `omni.audio.import_(...)`.
"""

from __future__ import annotations

import json
import keyword
import os
import socket
import sys
import threading
from pathlib import Path
from typing import Any

#: The contract version this client was written against. Hosts of the same
#: major version and any later minor accept it.
API_VERSION = "0.21.0"
PROTOCOL = "omnidaw-api"


class OmniError(RuntimeError):
    """An error the host answered with: `code`, `message`, optional `data`."""

    def __init__(self, method: str, error: dict[str, Any]):
        self.code = error.get("code")
        self.data = error.get("data")
        super().__init__(f"{method}: {self.code} {error.get('message')}")


def endpoint_file() -> Path:
    """Where Omni advertises its port and token."""
    if sys.platform == "win32":
        base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
    elif sys.platform == "darwin":
        base = Path.home() / "Library" / "Application Support"
    else:
        base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
    return base / "omni" / "api" / "endpoint.json"


class _Domain:
    def __init__(self, client: "Omni", name: str):
        self._client, self._name = client, name

    def __getattr__(self, method: str):
        # `omni.audio.import_(...)` for a method named like a Python keyword.
        if method.endswith("_") and keyword.iskeyword(method[:-1]):
            method = method[:-1]
        full = f"{self._name}.{method}"
        if full not in self._client.methods:
            raise AttributeError(f"Omni has no method {full}")

        def call(**params: Any) -> Any:
            return self._client.call(full, params)

        call.__doc__ = self._client.methods[full].get("summary", "")
        call.__name__ = method
        return call

    def __dir__(self):
        prefix = self._name + "."
        return [m[len(prefix):] for m in self._client.methods if m.startswith(prefix)]


class Omni:
    """One connection to a running Omni with the API enabled."""

    def __init__(self, client_name: str = "omnidaw.py", timeout: float = 600.0):
        # OMNI_API_ENDPOINT=host:port with OMNI_API_TOKEN, or the endpoint file.
        if os.environ.get("OMNI_API_ENDPOINT"):
            host, _, port = os.environ["OMNI_API_ENDPOINT"].rpartition(":")
            token = os.environ["OMNI_API_TOKEN"]
        else:
            ep = json.loads(endpoint_file().read_text(encoding="utf-8"))
            host, port, token = ep["host"], ep["port"], ep["token"]
        port = int(port)
        self._sock = socket.create_connection((host, port), timeout=timeout)
        self._file = self._sock.makefile("rwb")
        self._next_id = 0
        self._lock = threading.Lock()
        welcome = self.call("api.hello", {
            "token": token,
            "api_version": API_VERSION,
            "client": {"name": client_name, "version": API_VERSION},
        })
        self.api_version: str = welcome["api_version"]
        self.capabilities: list[str] = welcome.get("capabilities", [])
        doc = self.call("api.methods")
        self.methods: dict[str, dict] = {m["name"]: m for m in doc["methods"]}

    def call(self, method: str, params: dict | None = None) -> Any:
        """Call one method; returns its result or raises `OmniError`.

        Safe to call from several threads: one request is on the wire at a time.
        """
        with self._lock:
            self._next_id += 1
            request = {"jsonrpc": "2.0", "id": self._next_id, "method": method}
            if params:
                request["params"] = params
            self._file.write(json.dumps(request).encode() + b"\n")
            self._file.flush()
            line = self._file.readline()
        if not line:
            raise ConnectionError("Omni closed the connection")
        reply = json.loads(line)
        if "error" in reply:
            raise OmniError(method, reply["error"])
        return reply.get("result")

    def wait(self, job: int) -> Any:
        """Wait for a job (capture, export, transcription, ...) and return its result."""
        while True:
            info = self.call("jobs.wait", {"job": job})
            if info["state"] == "done":
                return info.get("result")
            if info["state"] != "running":
                raise OmniError("jobs.wait", {"code": None, "message": info.get("error")})

    def __getattr__(self, domain: str) -> _Domain:
        methods = self.__dict__.get("methods", {})
        if domain.startswith("_") or not any(m.startswith(domain + ".") for m in methods):
            raise AttributeError(domain)
        return _Domain(self, domain)

    def close(self) -> None:
        self._sock.close()

    def __enter__(self) -> "Omni":
        return self

    def __exit__(self, *exc) -> None:
        self.close()


if __name__ == "__main__":
    with Omni() as omni:
        print(f"Omni speaks OmniDAW API {omni.api_version}, "
              f"{len(omni.methods)} methods, capabilities {omni.capabilities}")
        print(omni.session.info())
