"""Minimal async client for a local Dolt clone.

The application never runs a long-lived Dolt sql-server; it shells out to the
`dolt` CLI against a persistent clone and reads results as CSV. Every call goes
through ``asyncio.create_subprocess_exec`` because the scheduler shares one event
loop with the API (`app/scheduler.py:73`) — a blocking `subprocess.run` here
would stall request handling.

Production keeps the clone in ``DOLT_DATA_DIR`` outside the deploy tree; the
binary path and data dir are configured (see ``app/config.py``). Read via
``dolt sql -r csv``; refresh with ``pull`` and record the resulting commit hash
as the import revision.
"""

from __future__ import annotations

import asyncio
import csv
import io
import logging
import shutil
from pathlib import Path

logger = logging.getLogger(__name__)

# Default subprocess timeout. A hung `dolt pull`/`sql` would otherwise pin the
# import's connection and its advisory lock indefinitely, so every call is
# bounded; callers may override per operation.
DEFAULT_TIMEOUT = 600.0


class DoltError(RuntimeError):
    """A dolt subprocess failed, timed out, or exited non-zero."""


async def _run(
    binary: str, args: list[str], *, cwd: Path, timeout: float = DEFAULT_TIMEOUT
) -> str:
    proc = await asyncio.create_subprocess_exec(
        binary,
        *args,
        cwd=str(cwd),
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    try:
        stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
    except asyncio.TimeoutError:
        proc.kill()
        try:
            await proc.wait()
        except ProcessLookupError:
            pass
        raise DoltError(f"dolt {args[0] if args else ''} timed out after {timeout:.0f}s")
    if proc.returncode != 0:
        raise DoltError(
            f"dolt {' '.join(args)} failed ({proc.returncode}): "
            f"{stderr.decode('utf-8', 'replace').strip()[:500]}"
        )
    return stdout.decode("utf-8", "replace")


def ensure_free_disk(path: Path, min_free_gb: float) -> None:
    """Raise if free space at ``path`` is below the threshold (checked before a
    pull that could grow the clone). Uses the nearest existing ancestor so it
    works before the clone dir exists."""
    probe = path
    while not probe.exists() and probe.parent != probe:
        probe = probe.parent
    free_gb = shutil.disk_usage(probe).free / (1024**3)
    if free_gb < min_free_gb:
        raise DoltError(
            f"insufficient disk for dolt at {path}: {free_gb:.1f} GB free "
            f"< {min_free_gb:.1f} GB required"
        )


async def pull(repo_dir: Path, *, binary: str, timeout: float = DEFAULT_TIMEOUT) -> None:
    """`dolt pull` the persistent clone to the latest upstream revision."""
    await _run(binary, ["pull"], cwd=repo_dir, timeout=timeout)


async def current_commit(
    repo_dir: Path, *, binary: str, timeout: float = DEFAULT_TIMEOUT
) -> str:
    """The HEAD commit hash of the clone — used as the import revision.

    Uses ``DOLT_HASHOF('HEAD')`` (which formally identifies HEAD) rather than
    ordering ``dolt_log`` by timestamp."""
    rows = await query_csv(
        repo_dir, "SELECT DOLT_HASHOF('HEAD') AS commit_hash", binary=binary, timeout=timeout
    )
    if not rows or not rows[0].get("commit_hash"):
        raise DoltError("could not read HEAD commit hash")
    return rows[0]["commit_hash"]


async def query_csv(
    repo_dir: Path, sql: str, *, binary: str, timeout: float = DEFAULT_TIMEOUT
) -> list[dict[str, str]]:
    """Run a read query and parse the CSV result into a list of dict rows."""
    out = await _run(binary, ["sql", "-q", sql, "-r", "csv"], cwd=repo_dir, timeout=timeout)
    if not out.strip():
        return []
    return list(csv.DictReader(io.StringIO(out)))
