#!/usr/bin/env python3
"""Dependency-free TEXELMATIC bulk processor CLI."""
from __future__ import annotations

import argparse
import hashlib
import json
import mimetypes
import os
import sys
import time
import uuid
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"}


class ApiError(RuntimeError):
    pass


def multipart(fields: dict[str, str]) -> tuple[bytes, str]:
    boundary = f"----texelmatic-{uuid.uuid4().hex}"
    pieces: list[bytes] = []
    for name, value in fields.items():
        pieces.extend((
            f"--{boundary}\r\n".encode(),
            f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
            str(value).encode(),
            b"\r\n",
        ))
    pieces.append(f"--{boundary}--\r\n".encode())
    return b"".join(pieces), f"multipart/form-data; boundary={boundary}"


class Client:
    def __init__(self, base_url: str, token: str) -> None:
        self.base_url = base_url.rstrip("/")
        self.token = token

    def request(self, method: str, path: str, *, data: bytes | None = None, content_type: str = "application/octet-stream", headers: dict[str, str] | None = None, binary: bool = False):
        request_headers = {"Authorization": f"Bearer {self.token}", "Accept": "application/json", "X-Requested-With": "fetch"}
        if data is not None:
            request_headers["Content-Type"] = content_type
        request_headers.update(headers or {})
        request = Request(f"{self.base_url}{path}", data=data, headers=request_headers, method=method)
        try:
            with urlopen(request, timeout=120) as response:
                payload = response.read()
                if binary:
                    return payload, dict(response.headers)
                return json.loads(payload.decode("utf-8")) if payload else {}
        except HTTPError as error:
            payload = error.read().decode("utf-8", "replace")
            try:
                message = json.loads(payload).get("message") or json.loads(payload).get("error")
            except ValueError:
                message = payload
            raise ApiError(f"HTTP {error.code}: {message or error.reason}") from error
        except URLError as error:
            raise ApiError(f"Connection failed: {error.reason}") from error

    def form(self, method: str, path: str, fields: dict[str, str]):
        data, content_type = multipart(fields)
        return self.request(method, path, data=data, content_type=content_type)

    def download(self, path: str, target: Path, progress) -> int:
        request = Request(
            f"{self.base_url}{path}",
            headers={
                "Authorization": f"Bearer {self.token}",
                "Accept": "application/zip",
                "X-Requested-With": "fetch",
            },
            method="GET",
        )
        temporary_target = target.with_name(f"{target.name}.part")
        target.parent.mkdir(parents=True, exist_ok=True)
        try:
            with urlopen(request, timeout=120) as response:
                try:
                    total = int(response.headers.get("Content-Length") or 0)
                except (TypeError, ValueError):
                    total = 0
                downloaded = 0
                with temporary_target.open("wb") as output:
                    while True:
                        chunk = response.read(1024 * 1024)
                        if not chunk:
                            break
                        output.write(chunk)
                        downloaded += len(chunk)
                        progress(downloaded, total)
                os.replace(temporary_target, target)
                return downloaded
        except HTTPError as error:
            payload = error.read().decode("utf-8", "replace")
            try:
                body = json.loads(payload)
                message = body.get("message") or body.get("error")
            except ValueError:
                message = payload
            raise ApiError(f"HTTP {error.code}: {message or error.reason}") from error
        except URLError as error:
            raise ApiError(f"Connection failed: {error.reason}") from error


def discover_files(values: list[str]) -> list[Path]:
    files: list[Path] = []
    for value in values:
        path = Path(value).expanduser()
        if path.is_dir():
            files.extend(sorted(item for item in path.iterdir() if item.is_file() and item.suffix.lower() in IMAGE_SUFFIXES))
        elif path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES:
            files.append(path)
        else:
            raise ApiError(f"Unsupported or missing image path: {path}")
    unique = list(dict.fromkeys(path.resolve() for path in files))
    if not unique:
        raise ApiError("No PNG, JPG, or WebP files were found.")
    return unique


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as source:
        for chunk in iter(lambda: source.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def upload_batch(client: Client, args) -> str:
    files = discover_files(args.paths)
    rows = []
    for path in files:
        print(f"Fingerprinting {path.name}", file=sys.stderr)
        rows.append({"name": path.name, "size": path.stat().st_size, "type": mimetypes.guess_type(path.name)[0] or "application/octet-stream", "sha256": sha256_file(path)})
    init = client.form("POST", "/api/v1/bulk/uploads/init", {"files_json": json.dumps(rows), "resume_id": args.resume_id or ""})
    upload_id = init["upload_id"]
    chunk_size = int(init.get("chunk_size") or 2 * 1024 * 1024)
    received = [int(value) for value in init.get("received", [])]
    print(f"Upload session: {upload_id}", file=sys.stderr)
    for match in (init.get("duplicate_matches") or {}).values():
        if match.get("reusable_pbr"):
            print(f"Existing PBR pack: {match.get('filename')} in {match.get('batch_label')} ({client.base_url}{match.get('review_url')})", file=sys.stderr)
    total = sum(path.stat().st_size for path in files)
    confirmed = sum(received)
    progress_width = 0
    for index, path in enumerate(files):
        offset = received[index] if index < len(received) else 0
        with path.open("rb") as source:
            source.seek(offset)
            while offset < path.stat().st_size:
                chunk = source.read(chunk_size)
                result = client.request("POST", f"/api/v1/bulk/uploads/{upload_id}/{index}", data=chunk, headers={"X-Chunk-Offset": str(offset)})
                new_offset = int(result["received"])
                confirmed += new_offset - offset
                offset = new_offset
                progress_label = f"Uploading {confirmed * 100 // total:3d}%  {path.name}"
                progress_width = max(progress_width, len(progress_label))
                print("\r" + progress_label.ljust(progress_width), end="", file=sys.stderr, flush=True)
    print(file=sys.stderr)
    fields = {"upload_id": upload_id}
    if args.project:
        fields["project_name"] = args.project
    result = client.form("POST", "/api/v1/bulk/batches", fields)
    return str(result["batch_id"])


def start_batch(client: Client, batch_id: str):
    return client.request("POST", f"/api/v1/bulk/batches/{batch_id}/start", data=b"")


def status_batch(client: Client, batch_id: str):
    return client.request("GET", f"/api/v1/bulk/batches/{batch_id}")


def status_text(payload) -> tuple[str, str]:
    run = payload.get("run") or {}
    status = str(run.get("status") or "staged")
    label = str(run.get("status_label") or status)
    completed = run.get("completed_results", 0)
    total = run.get("total_results", 0)
    line = f"{status}: {label} ({completed}/{total})"
    note = str(run.get("note") or "").strip()
    if note:
        line += f" - {note}"
    return status, line


def print_status(payload) -> str:
    status, line = status_text(payload)
    print(line)
    return status


def wait_batch(client: Client, batch_id: str, interval: float) -> str:
    interactive = bool(getattr(sys.stdout, "isatty", lambda: False)())
    previous_line = ""
    display_width = 0
    while True:
        payload = status_batch(client, batch_id)
        status, line = status_text(payload)
        if line != previous_line:
            if interactive:
                display_width = max(display_width, len(line))
                print("\r" + line.ljust(display_width), end="", flush=True)
            else:
                print(line)
            previous_line = line
        if status not in {"queued", "waiting", "running", "stale"}:
            if interactive:
                print()
            return status
        time.sleep(interval)


def format_bytes(value: int) -> str:
    amount = float(max(0, value))
    for unit in ("B", "KB", "MB", "GB"):
        if amount < 1024 or unit == "GB":
            return f"{amount:.1f} {unit}" if unit != "B" else f"{int(amount)} B"
        amount /= 1024
    return f"{amount:.1f} GB"


def download_progress(downloaded: int, total: int) -> None:
    width = 30
    if total > 0:
        ratio = min(1.0, downloaded / total)
        filled = min(width, int(width * ratio))
        bar = "#" * filled + "-" * (width - filled)
        label = f"Downloading [{bar}] {ratio * 100:5.1f}%  {format_bytes(downloaded)} / {format_bytes(total)}"
    else:
        offset = (downloaded // (1024 * 1024)) % width
        bar = "-" * offset + "#" + "-" * (width - offset - 1)
        label = f"Downloading [{bar}]  {format_bytes(downloaded)}"
    print("\r" + label.ljust(88), end="", file=sys.stderr, flush=True)


def download_batch(client: Client, batch_id: str, output: str) -> Path:
    target = Path(output or f"texelmatic-{batch_id}.zip").expanduser()
    try:
        downloaded = client.download(
            f"/api/v1/bulk/batches/{batch_id}/download",
            target,
            download_progress,
        )
    finally:
        print(file=sys.stderr)
    print(f"Downloaded {target} ({format_bytes(downloaded)})")
    return target


def parser() -> argparse.ArgumentParser:
    root = argparse.ArgumentParser(prog="texelmatic", description="TEXELMATIC private bulk processor CLI")
    root.add_argument("--base-url", default=os.getenv("TEXELMATIC_URL", "https://texelmatic.com"))
    root.add_argument("--token", default=os.getenv("TEXELMATIC_API_TOKEN", ""))
    commands = root.add_subparsers(dest="command", required=True)
    upload = commands.add_parser("upload", help="Upload and stage images or a directory")
    upload.add_argument("paths", nargs="+")
    upload.add_argument("--project", default="")
    upload.add_argument("--resume-id", default="")
    upload.add_argument("--start", action="store_true")
    upload.add_argument("--wait", action="store_true")
    upload.add_argument("--download", metavar="ZIP", default="")
    start = commands.add_parser("start", help="Start a staged batch")
    start.add_argument("batch_id")
    status = commands.add_parser("status", help="Show batch status")
    status.add_argument("batch_id")
    wait = commands.add_parser("wait", help="Wait for a batch to finish")
    wait.add_argument("batch_id")
    wait.add_argument("--interval", type=float, default=3.0)
    download = commands.add_parser("download", help="Download a completed batch ZIP")
    download.add_argument("batch_id")
    download.add_argument("--output", "-o", default="")
    return root


def main() -> int:
    args = parser().parse_args()
    if not args.token:
        print("Set TEXELMATIC_API_TOKEN or pass --token.", file=sys.stderr)
        return 2
    client = Client(args.base_url, args.token)
    try:
        if args.command == "upload":
            batch_id = upload_batch(client, args)
            print(batch_id)
            if args.start or args.wait or args.download:
                start_batch(client, batch_id)
            if args.wait or args.download:
                status = wait_batch(client, batch_id, 3.0)
                if status not in {"complete", "completed", "success"}:
                    return 1
            if args.download:
                download_batch(client, batch_id, args.download)
        elif args.command == "start":
            print_status(start_batch(client, args.batch_id))
        elif args.command == "status":
            print_status(status_batch(client, args.batch_id))
        elif args.command == "wait":
            return 0 if wait_batch(client, args.batch_id, args.interval) in {"complete", "completed", "success"} else 1
        elif args.command == "download":
            download_batch(client, args.batch_id, args.output)
        return 0
    except (ApiError, OSError, ValueError, KeyError) as error:
        print(f"Error: {error}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())