"""Scratchpad object definition."""

__all__ = ["Scratch"]

import asyncio
from dataclasses import dataclass, field
from itertools import count
from typing import TYPE_CHECKING, Any, Protocol

from ...aioops import aiexists, aiopen
from ...models import ClientInfo, MonitorInfo, VersionInfo
from ..interface import PluginContext
from .helpers import DynMonitorConfig, get_match_fn, mk_scratch_name
from .schema import SCRATCHPAD_SCHEMA, normalize_animation

if TYPE_CHECKING:
    from collections.abc import Callable

    import pyprland.plugins.scratchpads as _scratchpads_extension_m
    from pyprland.plugins.scratchpads import Extension

    class ClientPropGetter(Protocol):
        """type for the get_client_props function."""

        async def __call__(
            self,
            match_fn: Callable = ...,
            clients: list[ClientInfo] | None = None,
            **kw: Any,
        ) -> ClientInfo | None:
            pass


CLIENT_INFO_RETRY_COUNT = 5


@dataclass
class MetaInfo:
    """Meta properties."""

    initialized: bool = False
    should_hide: bool = False
    no_pid: bool = False
    last_shown: float | int = 0
    space_identifier: tuple[str, str] = ("", "")
    monitor_info: MonitorInfo | None = None
    extra_positions: dict[str, tuple[int, int]] = field(default_factory=dict)
    saved_size: dict[str, tuple[int, int]] = field(default_factory=dict)


class Scratch:  # {{{
    """A scratchpad state including configuration & client state."""

    # get_client_props: "ClientPropGetter"
    client_info: ClientInfo | None
    visible = False
    uid = ""
    monitor = ""
    pid = -1
    excluded_scratches: list[str]

    def __init__(self, uid: str, full_config: dict[str, Any], plugin: "Extension") -> None:
        """Initialize a scratchpad.

        Args:
            uid: Unique identifier for the scratchpad
            full_config: The full scratchpads configuration dictionary
            plugin: The scratchpad extension instance
        """
        self.ctx = PluginContext(plugin.log, plugin.state, plugin.backend)
        self.uid = uid
        self.set_config(full_config)
        self.client_info: ClientInfo | None = None
        self.meta = MetaInfo()
        self.extra_addr: set[str] = set()  # additional client addresses
        self.excluded_scratches: list[str] = []

    @property
    def forced_monitor(self) -> str | None:
        """Returns forced monitor if available, else None."""
        forced_monitor = self.conf.get_str("force_monitor")
        if forced_monitor and forced_monitor in self.ctx.state.active_monitors:
            return forced_monitor
        return None

    @property
    def animation_type(self) -> str:
        """Returns the configured animation (normalized: lowercase, separators stripped)."""
        return normalize_animation(self.conf.get_str("animation"))

    def _make_initial_config(self, config: dict) -> dict:
        """Return configuration for the scratchpad.

        Args:
            config: The full configuration dictionary
        """
        opts = {}
        scratch_config = config[self.uid]
        if "use" in scratch_config:
            inheritance = scratch_config["use"]
            if isinstance(inheritance, str):
                inheritance = [inheritance]

            for source in inheritance:
                if source in config:
                    opts.update(config[source])
                else:
                    text = f"Scratchpad {self.uid} tried to use {source}, but it doesn't exist"
                    self.ctx.log.exception(text)
        opts.update(scratch_config)
        return opts

    def set_config(self, full_config: dict[str, Any]) -> None:
        """Apply constraints to the configuration.

        Args:
            full_config: The full configuration dictionary
        """
        opts = self._make_initial_config(full_config)

        # Create schema-aware config
        self.conf = DynMonitorConfig(opts, opts.get("monitor", {}), self.ctx.state, log=self.ctx.log, schema=SCRATCHPAD_SCHEMA)

        # Apply constraints using self.conf for reads, writes go to underlying ref
        if self.conf.get_bool("preserve_aspect"):
            self.conf["lazy"] = True
        if not self.have_command:
            self.conf["match_by"] = "class"
        if not self.conf.get_bool("process_tracking"):
            self.conf["lazy"] = True
            if not self.conf.has_explicit("match_by"):
                self.conf["match_by"] = "class"
        if self.conf.get_bool("close_on_hide"):
            self.conf["lazy"] = True
        if self.ctx.state.hyprland_version < VersionInfo(0, 39, 0):
            self.conf["allow_special_workspaces"] = False

    def have_address(self, addr: str) -> bool:
        """Check if the address is the same as the client.

        Args:
            addr: The address to check
        """
        return addr == self.full_address or addr in self.extra_addr

    @property
    def have_command(self) -> bool:
        """Check if the command is provided."""
        return bool(self.conf.get("command"))

    async def initialize(self, ex: "_scratchpads_extension_m.Extension") -> None:
        """Initialize the scratchpad.

        Args:
            ex: The scratchpad extension instance
        """
        if self.meta.initialized:
            return
        if self.have_command:
            await self.update_client_info()
        else:
            m_client = await self.fetch_matching_client()
            if not m_client:
                match_by, match_val = self.get_match_props()
                msg = f"No window found matching {match_by}='{match_val}' - is the application running?"
                raise RuntimeError(msg)
            self.client_info = m_client
        await ex.backend.execute(f"movetoworkspacesilent {mk_scratch_name(self.uid)},address:{self.full_address}")
        self.meta.initialized = True

    async def is_alive(self) -> bool:
        """Is the process running ?."""
        if not self.have_command:
            return True
        if self.conf.get_bool("process_tracking"):
            path = f"/proc/{self.pid}"
            if await aiexists(path):
                async with aiopen(f"{path}/status", mode="r", encoding="utf-8") as f:
                    for line in await f.readlines():
                        if line.startswith("State"):
                            proc_state = line.split()[1]
                            return proc_state not in "ZX"  # not "Z (zombie)"or "X (dead)"
        else:
            if self.meta.no_pid:
                return bool(await self.fetch_matching_client())
            return False

        return False

    async def fetch_matching_client(self, clients: list[ClientInfo] | None = None) -> ClientInfo | None:
        """Fetch the first matching client properties.

        Args:
            clients: The list of clients
        """
        match_by, match_val = self.get_match_props()
        return await self.ctx.backend.get_client_props(
            match_fn=get_match_fn(match_by, match_val),
            clients=clients,
            **{match_by: match_val},
        )

    def get_match_props(self) -> tuple[str, str | float]:
        """Return the match properties for the scratchpad."""
        match_by = self.conf.get_str("match_by")
        if match_by == "pid":
            return match_by, self.pid
        # Dynamic key access - the match_by value (e.g., "class", "title") is used as a key
        match_value = self.conf.get(match_by)
        if match_value is None:
            return match_by, ""
        return match_by, str(match_value) if not isinstance(match_value, (int, float)) else match_value

    def reset(self, pid: int) -> None:
        """Clear the object.

        Args:
            pid: The process ID
        """
        self.pid = pid
        self.visible = False
        self.client_info = None
        self.meta.initialized = False
        self.meta.extra_positions.clear()
        self.meta.saved_size.clear()

    @property
    def client_ready(self) -> bool:
        """Check if the client info is available and contains size information."""
        return bool(self.client_info is not None and "size" in self.client_info)

    @property
    def address(self) -> str:
        """Return the client address (without 0x prefix)."""
        if self.client_info is None:
            return ""
        return self.client_info.get("address", "")[2:]

    @property
    def full_address(self) -> str:
        """Return the client address."""
        if self.client_info is None:
            return ""
        return self.client_info.get("address", "")

    async def update_client_info(
        self,
        client_info: ClientInfo | None = None,
        clients: list[ClientInfo] | None = None,
    ) -> None:
        """Update the internal client info property, if not provided, refresh based on the current address.

        Args:
            client_info: The client info
            clients: The list of clients
        """
        if client_info is None:
            counter = count(0)
            while next(counter) < CLIENT_INFO_RETRY_COUNT:
                if self.have_command:
                    client_info = await self.ctx.backend.get_client_props(addr=self.full_address, clients=clients)
                else:
                    client_info = await self.fetch_matching_client(clients=clients)
                if client_info and client_info["mapped"]:
                    break
                await asyncio.sleep(0.1)  # wait a bit before retrying

        if client_info is None:
            self.ctx.log.error("The client window %s vanished", self.full_address)
            msg = f"Client window {self.full_address} not found"
            raise KeyError(msg)

        self.client_info = client_info

    def event_workspace(self, name: str) -> None:
        """Check if the workspace changed.

        Args:
            name: The workspace name
        """
        if self.conf.get("pinned"):
            self.meta.space_identifier = name, self.meta.space_identifier[1]

    def __str__(self) -> str:
        return f"{self.uid} {self.address} : {self.client_info} / {self.conf}"


# }}}
