import json
import time
from collections import UserList, namedtuple
from dataclasses import dataclass, field
from functools import partial
from pathlib import Path
from typing import Any, Optional

from objc_types_decoder.decode import decode as decode_type
from objc_types_decoder.decode import decode_with_tail
from pygments import highlight
from pygments.formatters import TerminalTrueColorFormatter
from pygments.lexers import ObjectiveCLexer

from hilda.exceptions import GettingObjectiveCClassError

Ivar = namedtuple("Ivar", "name type_ offset")
Property = namedtuple("Property", "name attributes")
PropertyAttributes = namedtuple("PropertyAttributes", "synthesize type_ list")


def convert_encoded_property_attributes(encoded):
    conversions = {
        "R": lambda x: "readonly",
        "C": lambda x: "copy",
        "&": lambda x: "strong",
        "N": lambda x: "nonatomic",
        "G": lambda x: "getter=" + x[1:],
        "S": lambda x: "setter=" + x[1:],
        "d": lambda x: "dynamic",
        "W": lambda x: "weak",
        "P": lambda x: "<garbage-collected>",
        "t": lambda x: "encoding=" + x[1:],
    }

    type_, tail = decode_with_tail(encoded[1:])
    attributes = []
    synthesize = None
    for attr in filter(None, tail.lstrip(",").split(",")):
        if attr[0] in conversions:
            attributes.append(conversions[attr[0]](attr))
        elif attr[0] == "V":
            synthesize = attr[1:]

    return PropertyAttributes(type_=type_, synthesize=synthesize, list=attributes)


@dataclass
class Method:
    name: str
    client: Any = field(compare=False)
    address: int = field(compare=False)
    imp: int = field(compare=False)
    type_: str = field(compare=False)
    return_type: str = field(compare=False)
    is_class: bool = field(compare=False)
    args_types: list = field(compare=False)
    class_name: str = field(compare=False)

    @staticmethod
    def from_data(class_name: str, data: dict, client) -> "Method":
        """
        Create Method object from raw data.
        :param class_name: ObjC class name
        :param data: Data as loaded from get_objectivec_symbol_data.m.
        :param hilda.hilda_client.HildaClient client: Hilda client.
        """
        return Method(
            name=data["name"],
            client=client,
            address=client.symbol(data["address"]),
            imp=client.symbol(data["imp"]),
            type_=data["type"],
            return_type=decode_type(data["return_type"]),
            is_class=data["is_class"],
            args_types=list(map(decode_type, data["args_types"])),
            class_name=class_name,
        )

    def set_implementation(self, new_imp: int):
        self.client.symbols.method_setImplementation(self.address, new_imp)
        self.imp = self.client.symbol(new_imp)

    def monitor(self, **args) -> None:
        """
        Perform monitor on method's IMP.
        See monitor command for more details.
        :param args: given arguments for monitor command
        """
        self.client.symbol(self.imp).monitor(**args)

    def bp(self, **args) -> None:
        """
        Place a breakpoint on method's IMP.
        See bp command for more details.
        :param args: given arguments for bp command
        """
        self.client.symbol(self.imp).bp(**args)

    def __str__(self) -> str:
        if ":" in self.name:
            args_names = self.name.split(":")
            name = " ".join(["{}:({})".format(*arg) for arg in zip(args_names, self.args_types[2:])])
        else:
            name = self.name
        prefix = "+" if self.is_class else "-"
        return f"{prefix} {name}; // 0x{self.address:x} (returns: {self.return_type})\n"


class MethodList(UserList):
    def __init__(self, class_name: str, methods: list[Method]) -> None:
        super().__init__()
        self._class_name = class_name
        self.data = methods

    def get(self, name: str) -> Optional[Method]:
        """
        Get a specific method implementation.
        :param name: Method name.
        :return: Method.
        """
        for method in self.data:
            if method.name == name:
                return method
        return None

    # Actions

    def bp(self, callback=None, **kwargs):
        """
        Place a breakpoint on all symbols in the method list.
        Look for the bp command for more details.
        :param callback:  callback function to be executed upon an hit
        :param kwargs: optional kwargs for the bp command
        """
        for method in self.data:
            kwargs["name"] = f"[{self._class_name} {method.name}]"
            method.imp.bp(callback, **kwargs)

    def monitor(self, **kwargs):
        """
        Perform monitor for all symbols in the method list.
        See monitor command for more details.
        :param args: given arguments for monitor command
        """
        for method in self.data:
            method_kwargs = kwargs.copy()
            method_kwargs["name"] = f"{'+' if method.is_class else '-'}[{method.class_name} {method.name}]"
            method.imp.monitor(**method_kwargs)

    # Filters

    def filter_startswith(self, exp, case_sensitive=True):
        """
        Filter only methods with given prefix
        :param exp: prefix
        :param case_sensitive: is case sensitive
        :return: reduced method list
        """
        if not case_sensitive:
            exp = exp.lower()

        retval = []
        for v in self.data:
            name = v.name
            if not case_sensitive:
                name = name.lower()
            if name.startswith(exp):
                retval.append(v)
        return MethodList(self._class_name, retval)

    def filter_name_contains(self, exp, case_sensitive=True):
        """
        Filter methods containing a given expression
        :param exp: given expression
        :param case_sensitive: is case sensitive
        :return: reduced method list
        """
        if not case_sensitive:
            exp = exp.lower()

        retval = []
        for v in self.data:
            name = v.name
            if not case_sensitive:
                name = name.lower()
            if exp in name:
                retval.append(v)
        return MethodList(self._class_name, retval)


class Class:
    """
    Wrapper for ObjectiveC Class object.
    """

    def __init__(self, client, class_object=0, class_data: Optional[dict] = None):
        """
        :param hilda.hilda_client.HildaClient client:
        :param hilda.symbol.Symbol class_object:
        """
        self._client = client
        self._class_object = class_object
        self.protocols = []
        self.ivars = []
        self.properties = []
        self.name = ""
        self.methods = MethodList(self.name, [])
        self.super = None
        if class_data is None:
            self.reload()
        else:
            self._load_class_data(class_data)

    @staticmethod
    def from_class_name(client, class_name: str):
        """
        Create ObjectiveC Class from given class name.
        :param hilda.hilda_client.HildaClient client: Hilda client.
        :param class_name: Class name.
        """
        obj_c_code = (client._hilda_root / "objective_c" / "get_objectivec_class_description.m").read_text()
        obj_c_code = obj_c_code.replace("__class_address__", "0").replace("__class_name__", class_name)
        class_symbol = Class(client, class_data=json.loads(client.po(obj_c_code)))
        if class_symbol.name != class_name:
            raise GettingObjectiveCClassError()
        return class_symbol

    @staticmethod
    def sanitize_name(name: str):
        """
        Sanitize python name to ObjectiveC name.
        """
        name = "_" + name[1:].replace("_", ":") if name.startswith("_") else name.replace("_", ":")
        return name

    def reload(self):
        """
        Reload class object data.
        Should be used whenever the class layout changes (for example, during method swizzling)
        """
        obj_c_code = (self._client._hilda_root / "objective_c" / "get_objectivec_class_description.m").read_text()
        obj_c_code = obj_c_code.replace("__class_address__", f"{self._class_object:d}")
        obj_c_code = obj_c_code.replace("__class_name__", self.name)
        self._load_class_data(json.loads(self._client.po(obj_c_code)))

    def show(self):
        """
        Print to terminal the highlighted class description.
        """
        print(highlight(str(self), ObjectiveCLexer(), TerminalTrueColorFormatter(style="native")))

    def objc_call(self, sel: str, *args):
        """
        Invoke a selector on the given class object.
        :param sel: Selector name.
        :return: whatever the selector returned as a symbol.
        """
        return self._class_object.objc_call(sel, *args)

    def capture_self(self, sync: bool = False):
        """
        Capture the first called `self` from this class.
        Access using `self.captured_objects`
        :param sync: Should wait until captured object is returned?
        :return: Captured object if sync is True, None otherwise
        """
        class_name = self.name

        if class_name in self._client.captured_objects:
            del self._client.captured_objects[class_name]

        def hook(hilda, frame, bp_loc, options):
            hilda.log_info(f"self object has been captured from {options['name']}")
            hilda.log_info("removing breakpoints")
            for bp_id, bp in list(hilda.breakpoints.items()):
                if "group_uuid" in bp.options and bp.options.get("group_uuid", "") == options["group_uuid"]:
                    hilda.breakpoints.remove(bp_id)
            captured = hilda.evaluate_expression("$arg1")
            captured = captured.objc_symbol
            hilda.captured_objects[options["name"].split(" ")[0].split("[")[1]] = captured
            hilda.cont()

        for method in self.methods:
            if not method.is_class:
                # only instance methods are relevant for capturing self
                method.imp.bp(hook)

        if sync:
            self._client.cont()
            self._client.log_debug("Waiting for desired object to be captured...")
            while class_name not in self._client.captured_objects:
                time.sleep(1)

            return self._client.captured_objects[class_name]

    def monitor(self, **kwargs):
        """
        Proxy for monitor command.
        """
        self.methods.monitor(**kwargs)

    def bp(self, callback=None, **kwargs):
        """
        Proxy for bp command.
        """
        self.methods.bp(callback, **kwargs)

    def iter_supers(self):
        """
        Iterate over the super classes of the class.
        """
        sup = self.super
        while sup is not None:
            yield sup
            sup = sup.super

    @property
    def bundle_path(self) -> Path:
        return Path(
            self._client.symbols
            .objc_getClass("NSBundle")
            .objc_call("bundleForClass:", self._class_object)
            .objc_call("bundlePath")
            .py()
        )

    def _load_class_data(self, data: dict):
        self._class_object = self._client.symbol(data["address"])
        self.super = Class(self._client, data["super"]) if data["super"] else None
        self.name = data["name"]
        self.protocols = data["protocols"]
        self.ivars = [
            Ivar(
                name=ivar["name"],
                type_=decode_type(ivar["type"]) if ivar["type"] else "unknown_type_t",
                offset=ivar["offset"],
            )
            for ivar in data["ivars"]
        ]
        self.properties = [
            Property(name=prop["name"], attributes=convert_encoded_property_attributes(prop["attributes"]))
            for prop in data["properties"]
        ]
        self.methods = MethodList(
            self.name, [Method.from_data(self.name, method, self._client) for method in data["methods"]]
        )

    def __dir__(self):
        result = set()

        for method in self.methods:
            if method.is_class:
                result.add(method.name.replace(":", "_"))

        for sup in self.iter_supers():
            if self._client.configs.nsobject_exclusion and sup.name == "NSObject":
                continue
            for method in sup.methods:
                if method.is_class:
                    result.add(method.name.replace(":", "_"))

        result.update(list(super().__dir__()))
        return list(result)

    def __str__(self):
        protocol_buf = f"<{','.join(self.protocols)}>" if self.protocols else ""

        if self.super is not None:
            buf = f"@interface {self.name}: {self.super.name} {protocol_buf}\n"
        else:
            buf = f"@interface {self.name} {protocol_buf}\n"

        # Add ivars
        buf += "{\n"
        for ivar in self.ivars:
            buf += f"\t{ivar.type_} {ivar.name}; // 0x{ivar.offset:x}\n"
        buf += "}\n"

        # Add properties
        for prop in self.properties:
            buf += f"@property ({','.join(prop.attributes.list)}) {prop.attributes.type_} {prop.name};\n"

            if prop.attributes.synthesize is not None:
                buf += f"@synthesize {prop.name} = {prop.attributes.synthesize};\n"

        # Add methods
        for method in self.methods:
            buf += str(method)

        buf += "@end"
        return buf

    def __repr__(self):
        return f'<objC Class "{self.name}">'

    def __getitem__(self, item):
        for method in self.methods:
            if method.name == item:
                if method.is_class:
                    return partial(self.objc_call, item)
                else:
                    raise AttributeError(f"{self.name} class has an instance method named {item}, not a class method")

        for sup in self.iter_supers():
            for method in sup.methods:
                if method.name == item:
                    if method.is_class:
                        return partial(self.objc_call, item)
                    else:
                        raise AttributeError(
                            f"{self.name} class has an instance method named {item}, not a class method"
                        )

        raise AttributeError(f"""'{self.name}' class has no attribute {item}""")

    def __getattr__(self, item: str):
        return self[self.sanitize_name(item)]
