# ruff: noqa: F821, I001

from __future__ import annotations

import base64
import hashlib
import io
import time
import uuid
import warnings
from collections.abc import Callable
from functools import wraps
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from PIL.ImageFont import FreeTypeFont
    from router import Event

def checked(func: Callable) -> Callable:
    @wraps(func)
    async def call(self, ctx: Event) -> dict | bool:
        try: return await func(self, ctx)
        except (ValueError, OSError): return {"error": "invalid_input"}
        except RuntimeError as error: return {"error": getattr(error, "code", "unavailable")}
    return call


class Card:
    width, height = 1200, 700

    def __init__(self, document: dict, background: bytes | None = None) -> None:
        from PIL import Image, ImageDraw
        if not isinstance(document, dict) or set(document) - {"title", "subtitle", "status", "metrics", "footer", "background"}: raise ValueError("Invalid card document")
        metrics = document.get("metrics", [])
        if not isinstance(metrics, list) or len(metrics) > 6: raise ValueError("A card supports up to six metrics")
        self.document, self.fonts = document, {}
        self.image = Image.new("RGB", (self.width, self.height))
        self.draw = ImageDraw.Draw(self.image)
        for y in range(self.height):
            part = y / self.height
            self.draw.line((0, y, self.width, y), fill=(int(26 - 11 * part), int(20 - 6 * part), int(48 - 23 * part)))
        if background: self.background(background)

    def background(self, raw: bytes) -> None:
        from PIL import Image, ImageOps
        try:
            with warnings.catch_warnings():
                warnings.simplefilter("error", Image.DecompressionBombWarning)
                with Image.open(io.BytesIO(raw)) as source:
                    if source.width * source.height > 8_000_000: raise ValueError("The background exceeds eight million pixels")
                    if source.format not in ("PNG", "JPEG", "WEBP"): raise ValueError("Use a PNG, JPEG or WebP background")
                    fitted = ImageOps.fit(ImageOps.exif_transpose(source).convert("RGB"), self.image.size, method=Image.Resampling.LANCZOS)
                    self.image.paste(Image.blend(fitted, self.image, 0.72))
        except (Image.DecompressionBombWarning, Image.DecompressionBombError) as error:
            raise ValueError("The background exceeds the pixel limit") from error

    def font(self, size: int) -> FreeTypeFont:
        from PIL import ImageFont
        if size not in self.fonts: self.fonts[size] = ImageFont.truetype(str(Path(__file__).with_name("font.ttf")), size)
        return self.fonts[size]

    def text(self, point: tuple[int, int], value: str, size: int, color: str, width: int) -> None:
        if not isinstance(value, str) or len(value) > 256: raise ValueError("Card text must contain at most 256 characters")
        value = " ".join(value.split())
        font = self.font(size)
        if self.draw.textlength(value, font=font) > width:
            while value and self.draw.textlength(value + "…", font=font) > width: value = value[:-1]
            value += "…"
        self.draw.text(point, value, font=font, fill=color, anchor="lt")

    def build(self) -> bytes:
        from PIL import ImageDraw
        draw = ImageDraw.Draw(self.image, "RGBA")
        draw.ellipse((920, -130, 1370, 320), fill=(143, 94, 246, 17), outline=(178, 143, 255, 50), width=2)
        draw.ellipse((980, -70, 1310, 260), outline=(178, 143, 255, 45), width=2)
        draw.rounded_rectangle((40, 40, 1160, 660), radius=30, fill=(15, 12, 26, 190), outline=(160, 131, 220, 65), width=2)
        for x, y in ((76, 81), (94, 81), (76, 99), (94, 99)): draw.rounded_rectangle((x, y, x + 11, y + 11), 3, fill=(185, 148, 255, 255))
        self.text((130, 82), "CLUSTER", 22, "#c6a9ff", 300)
        self.text((80, 152), self.document.get("title", "Cluster"), 48, "#f5f1ff", 1010)
        self.text((82, 222), self.document.get("subtitle", ""), 22, "#b6a9cc", 1000)
        draw.ellipse((84, 286, 96, 298), fill=(133, 220, 175, 255))
        self.text((110, 282), self.document.get("status", "Running"), 18, "#a5e2bf", 980)
        for index, item in enumerate(self.document.get("metrics", [])):
            if not isinstance(item, dict) or set(item) != {"label", "value"}: raise ValueError("A metric needs a label and value")
            x, y = 80 + index % 3 * 352, 342 + index // 3 * 116
            draw.rounded_rectangle((x, y, x + 336, y + 100), 14, fill=(171, 132, 243, 13), outline=(171, 132, 243, 28))
            self.text((x + 20, y + 16), item["label"], 16, "#a99bbd", 295)
            self.text((x + 20, y + 47), item["value"], 26, "#efe7ff", 295)
        self.text((82, 610), self.document.get("footer", ""), 15, "#a397b8", 1035)
        result = io.BytesIO()
        self.image.save(result, "PNG", compress_level=6)
        raw = result.getvalue()
        if len(raw) > 2 * 1024 ** 2: raise ValueError("The rendered image is too large")
        return raw


class Module:
    def __init__(self) -> None: self.images, self.assets = {}, {}

    def prune(self) -> None:
        for values in (self.images, self.assets):
            for id, item in list(values.items()):
                if item["expires"] <= time.monotonic(): del values[id]

    @staticmethod
    def caller(ctx: Event) -> str:
        caller = ctx.data.get("caller")
        if not caller: raise ValueError("Call the generator from another module")
        return caller

    def entry(self, values: dict, id: str, caller: str) -> dict:
        self.prune()
        value = values.get(id)
        if value is None or value["caller"] != caller: raise ValueError("The image handle is unavailable")
        return value

    @loader.function(fields={"data": {"type": "string", "max": 24000, "required": True},
                             "offset": {"type": "integer", "min": 0, "max": 2097152, "default": 0},
                             "id": {"type": "string", "max": 64, "default": ""}})
    @checked
    async def upload(self, ctx: Event) -> dict:
        caller = self.caller(ctx)
        self.prune()
        id, offset = ctx.values["id"], ctx.values["offset"]
        raw = base64.b64decode(ctx.values["data"], validate=True)
        if not raw or len(raw) > 16384: raise ValueError("Invalid background chunk")
        if not id:
            if offset != 0 or len(self.assets) >= 4: raise ValueError("The background upload cannot start")
            id = uuid.uuid4().hex
            self.assets[id] = {"caller": caller, "data": bytearray(), "expires": time.monotonic() + 120}
        asset = self.entry(self.assets, id, caller)
        if offset != len(asset["data"]) or offset + len(raw) > 2 * 1024 ** 2: raise ValueError("Invalid background offset or size")
        asset["data"].extend(raw)
        return {"id": id, "offset": len(asset["data"])}

    @loader.function(fields={"document": {"type": "json", "required": True}})
    @checked
    async def render(self, ctx: Event) -> dict:
        caller = self.caller(ctx)
        self.prune()
        if len(self.images) >= 8: raise ValueError("Release a generated image before creating another")
        document = ctx.values["document"]
        if not isinstance(document, dict): raise ValueError("A card document is required")
        asset = document.get("background")
        background = bytes(self.entry(self.assets, asset, caller)["data"]) if asset else None
        raw = Card(document, background).build()
        file = await app.files.store("cluster-card.png", raw, ttl=120)
        self.images[file.id] = {"caller": caller, "file": file, "expires": time.monotonic() + 120}
        return {"id": file.id, "size": len(raw), "sha256": hashlib.sha256(raw).hexdigest(), "width": Card.width, "height": Card.height}

    @loader.function(fields={"id": {"type": "string", "max": 64, "required": True},
                             "offset": {"type": "integer", "min": 0, "max": 2097152, "default": 0}})
    @checked
    async def chunk(self, ctx: Event) -> dict:
        item = self.entry(self.images, ctx.values["id"], self.caller(ctx))
        offset = ctx.values["offset"]
        raw = await item["file"].read_at(offset, 16384)
        return {"data": base64.b64encode(raw).decode(), "offset": offset + len(raw), "size": item["file"].data["size"]}

    @loader.function(fields={"id": {"type": "string", "max": 64, "required": True}})
    @checked
    async def release(self, ctx: Event) -> bool:
        caller, id = self.caller(ctx), ctx.values["id"]
        if id in self.assets:
            self.entry(self.assets, id, caller)
            del self.assets[id]
        elif id in self.images:
            item = self.entry(self.images, id, caller)
            await item["file"].delete()
            del self.images[id]
        return True
