from typing import NamedTuple, List, Mapping, Optional, Tuple, Set, DefaultDict
from collections import defaultdict
import pickle
import argparse

import os
import io
import re

import pcpp
from pycparser import c_ast, CParser

g_failed = False

def verbose(msg):
    print(msg, flush=True)

def error(msg):
    global g_failed
    print(f"\n{msg}", flush=True)
    g_failed = True

fake_includes = { }

fake_includes["stdint.h"] = """
#pragma once
typedef unsigned char uint8_t;
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef unsigned long uint64_t;
typedef signed char int8_t;
typedef signed short int16_t;
typedef signed int int32_t;
typedef signed long int64_t;
"""

fake_includes["stdbool.h"] = """
#pragma once
#define bool _Bool
#define true 1
#define false 0
"""

fake_includes["stddef.h"] = """
typedef unsigned long size_t;
typedef unsigned long uintptr_t;
typedef long ptrdiff_t;
"""

fake_includes["stdarg.h"] = """
typedef int va_list;
"""

fake_includes["stdio.h"] = """
typedef int FILE;
typedef unsigned long fpos_t;
"""

fake_includes["string.h"] = ""
fake_includes["stdlib.h"] = ""
fake_includes["locale.h"] = ""
fake_includes["math.h"] = ""
fake_includes["assert.h"] = ""

class Preprocessor(pcpp.Preprocessor):
    def __init__(self):
        super().__init__()
    
    def on_file_open(self, is_system_include, includepath):
        if is_system_include and os.path.basename(includepath) in fake_includes:
            return io.StringIO(fake_includes[os.path.basename(includepath)])
        return super().on_file_open(is_system_include, includepath)

class ValuePreprocessor(pcpp.Preprocessor):
    def __init__(self, func: str, expr: str):
        super().__init__()
        self.func = func
        self.expr = expr
    
    def on_unknown_macro_in_expr(self,tok):
        raise RuntimeError(f"Bad recursion bound on function {self.func}: '{self.expr}'")

class FuncInfo:
    stack_usage: int
    max_recursion: Optional[int]
    calls: Set[str]
    defined: bool
    recursion_stack: List[str]

    def __init__(self):
        self.stack_usage = 0
        self.max_recursion = None
        self.calls = set()
        self.defined = False
        self.recursion_stack = []
        self.largest_call = ""

class StackUsage(NamedTuple):
    usage: int
    largest_call: str

class File:
    functions: DefaultDict[str, FuncInfo]
    stack_usage: Mapping[str, StackUsage]
    addresses: Set[str]
    max_dynamic_usage: int
    recursion_errors: Mapping[str, List[str]]

    def __init__(self):
        self.functions = defaultdict(FuncInfo)
        self.stack_usage = {}
        self.addresses = set()
        self.max_dynamic_usage = 0
        self.recursion_errors = {}

class AstVisitor(c_ast.NodeVisitor):
    file: File
    current_func: str

    def __init__(self, file: File):
        self.file = file
        self.current_func = None

    def visit_FuncDef(self, node: c_ast.FuncDecl):
        if node.body:
            func = node.decl.name
            if not (func.startswith("ufbx_") or func.startswith("ufbxi_")):
                error(f"Bad function definition: {func}")
            self.file.functions[func].defined = True

            self.current_func = node.decl.name
            self.visit(node.body)
            self.current_func = None

    def visit_UnaryOp(self, node: c_ast.UnaryOp):
        if node.op == "&" and isinstance(node.expr, c_ast.ID):
            self.file.addresses.add(node.expr.name)
        self.visit(node.expr)

    def visit_FuncCall(self, node: c_ast.FuncCall):
        src = self.current_func
        if src:
            dst = node.name.name
            if (isinstance(dst, str) and (dst.startswith("ufbxi_") or dst.startswith("ufbx_"))):
                self.file.functions[src].calls.add(dst)
        if node.args:
            self.visit(node.args)

def get_stack_usage_to(file: File, func: str, target: str, seen: Set[str] = set()) -> Optional[Tuple[int, List[str]]]:
    if func in seen:
        return (0, []) if func == target else None
    info = file.functions.get(func)
    if not info:
        raise RuntimeError(f"Function not found: {func}")

    seen = seen | { func }

    max_path = None
    for call in info.calls:
        path = get_stack_usage_to(file, call, target, seen)
        if path is not None:
            if max_path:
                max_path = max(max_path, path)
            else:
                max_path = path

    if max_path:
        max_usage, stack = max_path
        usage = info.stack_usage + max_usage
        return (usage, [func] + stack)
    else:
        return None

def add_ignore(ignores: str, ignore: str) -> str:
    if not ignores: ignores = "/"
    parts = ignores[1:].split(",") + [ignore]
    return "/" + ",".join(sorted(p for p in parts if p))

def is_ignored(ignores: str, func: str) -> bool:
    return ignores and func in ignores[1:].split(",")

def get_stack_usage(file: File, func: str, ignores: str = "", stack: List[str] = []) -> StackUsage:
    if is_ignored(ignores, func):
        return StackUsage(-1, "")

    key = f"{func}{ignores}" if ignores else func
    existing = file.stack_usage.get(key)
    if existing is not None: return existing

    info = file.functions.get(func)
    if not info:
        raise RuntimeError(f"Function not found: {func}")

    if info.max_recursion:
        rec_path = get_stack_usage_to(file, func, func)
        if rec_path is None:
            error(f"Unnecessary recursion tag in {func}()\nContains ufbxi_assert_max_recursion() but could not find recursive path to itself")
            rec_path = (0, [])

        rec_usage, rec_stack = rec_path
        info.recursion_stack = rec_stack
        self_usage = rec_usage * (info.max_recursion - 1) + info.stack_usage
        child_ignores = add_ignore(ignores, func)
        stack = []
    else:
        self_usage = info.stack_usage
        child_ignores = ignores

    if func in stack:
        pos = stack.index(func)
        error_stack = stack[pos:] + [func]
        prev_error = file.recursion_errors.get(func)
        if not prev_error or len(prev_error) > len(error_stack):
            file.recursion_errors[func] = error_stack
        return StackUsage(0, "")

    stack = stack + [func]

    max_usage = StackUsage(0, "")
    for call in info.calls:
        usage = get_stack_usage(file, call, child_ignores, stack).usage
        max_usage = max(max_usage, StackUsage(usage, f"{call}{child_ignores}"))

    usage = StackUsage(self_usage + max_usage.usage, max_usage.largest_call)
    file.stack_usage[key] = usage
    return usage

def parse_file(c_path: str, su_path: str, cache_path: Optional[str]) -> File:
    pp = Preprocessor()
    pp.define("UFBX_STANDARD_C")
    pp.define("UFBXI_ANALYSIS_PARSER")
    pp.define("UFBXI_ANALYSIS_RECURSIVE")

    if cache_path and os.path.exists(cache_path) and os.path.getmtime(cache_path) > os.path.getmtime(c_path):
        verbose(f"Loading AST cache: {cache_path}")
        with open(cache_path, "rb") as f:
            ast, max_recursions = pickle.load(f)
    else:
        max_recursions = { }

        verbose(f"Preprocessing C file: {c_path}")
        pp_stream = io.StringIO()
        with open(c_path) as f:
            pp.parse(f.read(), "ufbx.c")
            pp.write(pp_stream)
        pp_source = pp_stream.getvalue()

        re_recursive_function = re.compile(r"UFBXI_RECURSIVE_FUNCTION\s*\(\s*(\w+),\s*(.+)\s*\);")
        for line in pp_source.splitlines():
            m = re_recursive_function.search(line)
            if m:
                name, rec_expr = m.groups()
                lit_pp = ValuePreprocessor(name, rec_expr)
                toks = lit_pp.tokenize(rec_expr)
                rec_value, _ = lit_pp.evalexpr(toks)
                if not rec_value:
                    raise RuntimeError(f"Bad recursion bound on function {name}: '{rec_expr}'")
                max_recursions[name] = rec_value

        pp_source = re_recursive_function.sub("", pp_source)

        verbose("Parsing C file")
        parser = CParser()
        ast = parser.parse(pp_source)

        if cache_path:
            verbose(f"Writing AST cache: {cache_path}")
            with open(cache_path, "wb") as f:
                pickle.dump((ast, max_recursions), f)

    verbose("Visiting AST")
    file = File()
    visitor = AstVisitor(file)
    visitor.visit(ast)

    # Gather maximum recursion from file
    for func, rec in max_recursions.items():
        file.functions[func].max_recursion = rec

    if su_path:
        verbose(f"Reading stack usage file: {su_path}")
        with open(su_path) as f:
            for line in f:
                line = line.strip()
                if not line: continue
                m = re.match(r".*:\d+:(?:\d+:)?(\w+)(?:\.[a-z0-9\.]+)?\s+(\d+)\s+([a-z,]+)", line)
                if not m:
                    raise RuntimeError(f"Bad .su line: {line}")
                func, stack, usage = m.groups()
                assert usage in ("static", "dynamic,bounded")
                file.functions[func].stack_usage = int(stack)

    return file

def get_max_dynamic_usage(file: File) -> Tuple[int, str]:
    max_dynamic_usage = (0, "")

    addr_funcs = file.addresses & set(file.functions.keys())
    for func in addr_funcs:
        usage = get_stack_usage(file, func).usage
        max_dynamic_usage = max(max_dynamic_usage, (usage, func))

    return max_dynamic_usage

def dump_largest_stack(file: File, func: str) -> List[str]:
    usage = file.stack_usage[func]
    print(f"{func}() {usage.usage} bytes")
    index = 0
    while True:
        if "/" in func:
            raw_func, _ = func.split("/")
        else:
            raw_func = func
        info = file.functions.get(raw_func)
        stack_usage = file.stack_usage.get(func)
        if not info: break

        if info.recursion_stack:
            rec_usage = 0
            for ix, frame in enumerate(info.recursion_stack):
                rec_info = file.functions[frame]
                ch = "|" if ix > 0 else ">"
                fn = f"{frame}()"
                usage = f"+{rec_info.stack_usage} bytes"
                rec_usage += rec_info.stack_usage
                print(f"{ch}{index:3} {fn:<40}{usage:>14}")
                index += 1

            rec_extra = info.max_recursion - 1
            usage = f"(+{rec_usage * rec_extra} bytes)"
            prefix = f"(recursion {info.max_recursion} times)"
            index += rec_extra * len(info.recursion_stack)
            dots = "." * len(str(index - 1))
            print(f"|{dots:>3} {prefix:<39}{usage:>16}")

        fn = f"{raw_func}()"
        usage = f"+{info.stack_usage} bytes"
        print(f"{index:4} {fn:<40}{usage:>14}")
        func = stack_usage.largest_call
        index += 1

if __name__ == "__main__":
    parser = argparse.ArgumentParser("analyze_stack.py")
    parser.add_argument("su", nargs="?", help="Stack usage .su file")
    parser.add_argument("--source", help="Path to ufbx.c")
    parser.add_argument("--cache", help="Cache file to use")
    parser.add_argument("--limit", help="Maximum stack usage in bytes")
    parser.add_argument("--no-su", action="store_true", help="Allow running with no .su file")
    argv = parser.parse_args()

    if argv.su:
        su_path = argv.su
    elif not argv.no_su:
        raise RuntimeError("Expected an .su file as a positional argument")
    else:
        su_path = ""

    if argv.source:
        c_path = argv.source
    else:
        c_path = os.path.relpath(os.path.join(os.path.dirname(__file__), "..", "ufbx.c"))

    file = parse_file(c_path, su_path, argv.cache)

    if su_path:
        file.max_dynamic_usage = get_max_dynamic_usage(file)

        max_usage = (0, "")
        for func in file.functions:
            usage = get_stack_usage(file, func)
            max_usage = max(max_usage, (usage.usage, func))

        if argv.limit:
            limit = int(argv.limit, base=0)
            total = max_usage[0] + file.max_dynamic_usage[0]
            if total >= limit:
                error(f"Stack overflow in {max_usage[1]}: {max_usage[0]} bytes + {file.max_dynamic_usage[0]} dynamic\noverflows limit of {limit} bytes")
                print("\nLargest stack:")
                dump_largest_stack(file, max_usage[1])
                print("\nLargest dynamic stack:")
                dump_largest_stack(file, file.max_dynamic_usage[1])

        for func, stack in file.recursion_errors.items():
            stack_str = "\n".join(f"{ix:3}: {s}()" for ix, s in enumerate(stack))
            error(f"Unbounded recursion in {func}()\nStack trace:\n{stack_str}")

        if not g_failed:
            interesting_functions = [
                "ufbx_load_file",
                "ufbx_evaluate_scene",
                "ufbx_subdivide_mesh",
                "ufbx_tessellate_nurbs_surface",
                "ufbx_tessellate_nurbs_curve",
                "ufbx_evaluate_transform",
                "ufbx_generate_indices",
                "ufbx_inflate",
                "ufbx_triangulate_face",
            ]

            for func in interesting_functions:
                print()
                dump_largest_stack(file, func)

            print()
            print("Largest potentially dynamically called stack:")
            dump_largest_stack(file, file.max_dynamic_usage[1])
    else:
        print("Skipping further tests due to no .su file specified")

    if g_failed:
        exit(1)
    else:
        print()
        print("Success!")
