import glob
import os
import re
import zipfile
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
from typing import IO
from xml.etree import ElementTree
from xml.etree.ElementTree import Element

import yaml

from . import __version__, util

ROOT = Path(__file__).absolute().parent


@dataclass
class Library:
    variables: set[str]
    loopvariables: set[str]
    calls: set[str]
    functions: set[str]
    jumps: set[str]
    tags: set[str]
    # if true, accumulate everything found to library
    teaching: bool


# ruff lead this alone man
emit = print


def do_check(
    libs: list[str],
    targets: list[str],
    teaching: bool,
    nostdlib: bool = False,
) -> dict[str, str | dict[str, list[str]]]:
    emit(f"AnyErpLintVersion: {__version__.strip()}")

    lib_vars = Library(
        variables=set(),
        calls=set(),
        functions=set(),
        jumps=set(),
        tags=set(),
        teaching=teaching,
        loopvariables=set(),
    )

    # always feed the "standard library, except when unit testing (nostdlib)

    if not nostdlib:
        local_app_data = util.get_app_local_dir()
        feed_lib(lib_vars, local_app_data)

    for lib in libs:
        feed_lib(lib_vars, Path(lib))

    has_errors = False
    all_errors: dict[str, str | dict[str, list[str]]] = {}
    for target in targets:
        if "*" in target:
            errs = check_files(
                lib_vars, (Path(path) for path in glob.glob(target, recursive=True))
            )
        elif os.path.isdir(target):
            errs = check_files(lib_vars, Path(target).glob("**/*.xml"))
        else:
            errs = check_files(lib_vars, [Path(target)])
        if errs:
            all_errors.update(errs)
            has_errors = True

    if lib_vars.teaching:
        write_lib(lib_vars)

    if has_errors:
        emit("Errors found: >")
        error_counts = {
            f: (sum(len(l) for l in e.values()) if not isinstance(e, str) else -1)
            for f, e in all_errors.items()
        }
        rep = sorted((k, v) for (k, v) in error_counts.items())
        for line in rep:
            emit("  ", line[0], ";", line[1])

    return all_errors


def write_lib(lib_vars: Library) -> None:
    def write_file(fname: Path, lines: list[str]) -> None:
        emit("  - ", fname)
        f = open(fname, "wb")
        for line in lines:
            try:
                if not line.strip().isascii():
                    continue
                enc = line.strip().encode()
            except UnicodeEncodeError:
                # skip bad lines for now
                continue
            f.write(enc)
            f.write(b"\n")

    emit("Writing found function to:")

    stdlib = util.get_app_local_dir()
    calls_file = stdlib / "builtin_calls.txt"
    write_file(
        calls_file,
        sorted(call for call in lib_vars.calls if is_valid_call(call)),
    )

    functions_file = stdlib / "builtin_functions.txt"
    write_file(functions_file, sorted(lib_vars.functions))

    jumps_file = stdlib / "builtin_jumps.txt"
    write_file(jumps_file, sorted(lib_vars.jumps))

    tags_file = stdlib / "builtin_tags.txt"
    write_file(tags_file, sorted(lib_vars.tags))

    vars_file = stdlib / "builtin_vars.txt"
    write_file(vars_file, sorted(lib_vars.variables))

    loopvars_file = stdlib / "builtin_loopvars.txt"
    write_file(loopvars_file, sorted(lib_vars.loopvariables))


def is_valid_call(call: str) -> bool:
    return True


def feed_lib(lib_vars: Library, libdir: Path) -> None:
    def feed_set(set: set[str], fobj: IO[bytes]) -> None:
        set.update(line.decode().strip() for line in fobj.readlines())

    def visit_file(fname: str, fobj: IO[bytes]) -> None:
        if fname.endswith("_calls.txt"):
            feed_set(lib_vars.calls, fobj)
        elif fname.endswith("_functions.txt"):
            feed_set(lib_vars.functions, fobj)
        elif fname.endswith("_jumps.txt"):
            feed_set(lib_vars.jumps, fobj)
        elif fname.endswith("_tags.txt"):
            feed_set(lib_vars.tags, fobj)
        elif fname.endswith("_vars.txt"):
            feed_set(lib_vars.variables, fobj)
        elif fname.endswith("_loopvars.txt"):
            feed_set(lib_vars.loopvariables, fobj)

    if not libdir.exists():
        return

    # files on file system
    for p in libdir.glob("*_*.txt"):
        visit_file(str(p), p.open("rb"))

    # files in all the zip files
    for f in libdir.glob("*.zip"):
        zf = zipfile.ZipFile(f, "r")
        for zn in zf.namelist():
            visit_file(zn, zf.open(zn, "r"))


def report_fatal(fname: Path, ex: Exception) -> str:
    message = f"FATAL, {ex}"
    emit(f"{fname}: {message}")
    return message


def should_skip_file(fpath: Path) -> bool:
    if fpath.suffix != ".xml":
        return True
    with util.open_text_file(fpath) as f:
        text = f.read(10000)
        return not ("<erpConnector" in text or "<section" in text)


def check_file(lib_vars: Library, file: Path) -> str | dict[str, list[str]] | None:
    try:
        if should_skip_file(file):
            emit(f"{file}: SKIP nontemplate")
            return None
        r = parse_file(file, lib_vars.teaching)
    except (ElementTree.ParseError, PermissionError, UnicodeDecodeError) as e:
        return report_fatal(file, e)

    return report(lib_vars, str(file), r)


def check_files(
    lib_vars: Library, files: Iterable[Path]
) -> dict[str, str | dict[str, list[str]]]:
    errs: dict[str, str | dict[str, list[str]]] = {}
    for f in files:
        errlist = check_file(lib_vars, f)
        if errlist:
            errs[str(f)] = errlist

    return errs


@dataclass
class Parsed:
    var_decl: set[str]
    var_used: set[str]
    alltags: set[str]
    calls: set[str]
    jumps: set[str]
    syntax_errors: list[str]
    loop_var_decl: set[str]
    loop_var_use: set[str]
    used_functions: set[str]
    full_content: str


def add_linenumbers(cont: str, needles: list[str]) -> list[str]:
    hits: list[list[int]] = [[] for i in range(len(needles))]
    for linenum, line in enumerate(cont.splitlines(), 1):
        for _in, n in enumerate(needles):
            if n in line:
                hits[_in].append(linenum)

    return [
        n + " - line " + ", ".join(map(str, hits[idx]))
        for (idx, n) in enumerate(needles)
    ]


def report(lib_vars: Library, fname: str, p: Parsed) -> dict[str, list[str]]:
    undeclared_vars = p.var_used - p.var_decl
    undeclared_vars.difference_update(lib_vars.variables)
    unknown_loop_variables = p.loop_var_use - p.loop_var_decl
    unknown_loop_variables.difference_update(lib_vars.loopvariables)

    if lib_vars.teaching:
        lib_vars.calls.update(p.calls)
        lib_vars.functions.update(f for f in p.used_functions if f.isupper())
        lib_vars.jumps.update(p.jumps)
        lib_vars.tags.update(p.alltags)
        lib_vars.variables.update(undeclared_vars)
        lib_vars.loopvariables.update(unknown_loop_variables)

    errors = {}

    if undeclared_vars:
        errors["Unknown variables"] = add_linenumbers(
            p.full_content,
            sorted(undeclared_vars),
        )

    unknown_calls = p.calls
    unknown_calls.difference_update(lib_vars.calls)
    if unknown_calls:
        errors["Unknown calls"] = sorted(unknown_calls)

    unknown_functions = p.used_functions
    unknown_functions.difference_update(lib_vars.functions)
    if unknown_functions:
        errors["Unknown functions"] = add_linenumbers(
            p.full_content,
            sorted(unknown_functions),
        )

    unknown_jumps = p.jumps
    unknown_jumps.difference_update(lib_vars.jumps)
    if unknown_jumps:
        errors["Unknown jumps"] = sorted(unknown_jumps)

    unknown_tags = p.alltags
    unknown_tags.difference_update(lib_vars.tags)
    if unknown_tags:
        errors["Unknown tags"] = sorted(unknown_tags)

    if p.syntax_errors:
        errors["Other errors"] = list(p.syntax_errors)

    if unknown_loop_variables:
        errors["Unknown loop variables"] = sorted(unknown_loop_variables)

    if errors:
        emit(yaml.dump({fname: errors}, width=200).strip())

    return errors


key_params: dict[str, str] = {
    "bw_file_functions": "command",
    "bw_table_method": "command",
    "bw_string_functions": "operation",
    "bw_ws_function": "method",
}


def summarize_call(node: Element) -> str:
    name = node.attrib["name"].lower()
    full = name
    params = {
        p.attrib["name"]: (p.text or "TEXTMISSING") for p in node.iter("parameter")
    }
    suboperation_param_name = key_params.get(name)
    if suboperation_param_name:
        suboperation = params.get(suboperation_param_name, "UNK")
        full += "." + suboperation

    return (full + " - " + ",".join(sorted(params))).strip()


def summarize_output(node: Element) -> str:
    type = node.attrib.get("type", "NOTYPE")
    params = {p.attrib.get("name", "NONAME") for p in node.iter("parameter")}

    return ("Output " + type + " - " + ",".join(sorted(params))).strip()


def summarize_tag(node: Element) -> str:
    at = " " + " ".join(sorted(node.attrib.keys())) if node.attrib else ""
    full = "<" + node.tag + at + ">"
    return full


def brace_check(s: str) -> list[str]:
    stack: list[tuple[str, int]] = []
    lines = s.splitlines()
    closers = {"{": "}", "[": "]", "(": ")"}
    errors: list[str] = []
    for lnum, line in enumerate(lines, 1):
        flush_stack = False
        in_quote = False
        for cnum, ch in enumerate(line, 1):
            if ch == '"':
                # only care about quotes if we are in some nested operation already, top level quotes are not considered
                if stack:
                    in_quote = not in_quote

            if in_quote:
                continue

            if ch in "{([":
                stack.append((ch, lnum))
            if ch in "})]":
                try:
                    from_stack, _ = stack.pop()
                except IndexError:
                    errors.append(
                        f"Too many closing braces at line {lnum}, looking at '{ch}' on col {cnum}: ==> {line[cnum-10:cnum]} <==: {line.strip()}",
                    )
                    flush_stack = True
                    break

                expected = closers[from_stack]
                if expected != ch:
                    errors.append(
                        f"Expected brace {expected}, got {ch} at line {lnum} col {cnum}: {line.strip()}",
                    )
                    flush_stack = True
                    break
        if flush_stack:
            stack = []
    if stack:
        pretty_stack = ", ".join(f"{ch} {l}" for ch, l in stack)
        errors.append(
            f"File ended with mismatched braces, remaining in stack (char, linenum): {pretty_stack}",
        )
    return errors


# xxx not really needed due to new logic
MAGIC_VAR_NAMES = {"error", "return", "response", "invoice.i"}


def describe_node(n: Element) -> str:
    return "<" + n.tag + str(n.attrib) + ">"


def describe_jump(n: Element) -> str:
    params = sorted(
        child.attrib.get("name", "NONAME").strip() for child in n.iter("parameter")
    )
    target = n.attrib.get("jumpToXPath", "NOXPATH")
    prefix = "//section[@name='"
    if target.startswith(prefix):
        target = "..." + target[len(prefix) :].rstrip("]'")

    desc = (
        "Jump "
        + n.attrib.get("jumpToXmlFile", "NOFILE")
        + " -- "
        + target
        + " -- "
        + " ".join(params)
    )
    return desc.strip()


def _replace_with_empty(match: re.Match[str]) -> str:
    comment = match.group(0)
    empty_lines = "\n" * comment.count("\n")
    return empty_lines


def replace_commented_xml_with_empty_lines(xml_string: str) -> str:
    comment_pattern = "<!--(.*?)-->"
    result = re.sub(comment_pattern, _replace_with_empty, xml_string, flags=re.DOTALL)
    return result


def replace_cdata_with_empty_lines(xml_string: str) -> str:
    cdata_pattern = r"<!\[CDATA\[(.*?)\]\]>"
    result = re.sub(cdata_pattern, _replace_with_empty, xml_string, flags=re.DOTALL)
    return result


def is_illegal_password(name: str, value: str) -> bool:
    if "passw" not in name.lower():
        return False
    stripped = (value or "").strip()
    if not stripped:
        return False
    if stripped.startswith("{"):
        # password should always be references to variables or expressions, never literal values
        return False
    return True


XMLWRITER_COMMAND_PAIRS = {
    "startattribute": "endattribute",
    "startdocument": "enddocument",
    "startelement": "endelement",
}

XMLWRITER_COMMAND_SINGLE = {"write", "writeraw"}


def check_single_outputresource(elem: Element, output_location: str) -> str | None:
    cmd_stack = []
    for ind, cmd in enumerate(elem.iter("command"), 1):
        cmd_type = cmd.attrib.get("type")
        if not cmd_type:
            return f"Command missing 'type' attribute in OutputResource ({output_location}, command {ind})"
        elif cmd_type in XMLWRITER_COMMAND_SINGLE:
            continue
        elif cmd_type in XMLWRITER_COMMAND_PAIRS.keys():
            cmd_stack.append(cmd_type)
        elif cmd_type in XMLWRITER_COMMAND_PAIRS.values():
            try:
                prev = cmd_stack.pop()
            except IndexError:
                return f"Invalid command '{cmd_type}' in OutputResource, no matching starting tag ({output_location}, command {ind})"
            expected = XMLWRITER_COMMAND_PAIRS.get(prev)
            if cmd_type != expected:
                return f"Invalid command type '{cmd_type}' in OutputResource, expected '{expected}' ({output_location}, command {ind})"
        else:
            return f"Unknown command type '{cmd_type}' in OutputResource ({output_location}, command {ind})"

    if cmd_stack:
        return f"Did not end all started items in OutputResource, unterminated: {','.join(cmd_stack)} ({output_location})"
    return None


def check_outputresources(root: Element) -> list[str]:
    errors = []
    for outputresource in root.iter("output"):
        if outputresource.get("type", "").split(".")[-1] == "XmlWriterOutputResource":
            description = util.format_xml_tag(outputresource)
            cmd_error = check_single_outputresource(outputresource, description)
            if cmd_error:
                errors.append(cmd_error)
    return errors


def find_invalid_expressions(content: str) -> list[str]:
    expr_regex = r"{(.*?)}"

    def is_invalid_expression(expr: re.Match[str]) -> bool:
        if "," not in expr.group(1):
            # Regular expressions can contain things like {3}
            if expr.group(1).isnumeric():
                return False
            return True
        return False

    return [
        f"Invalid expression {expr.group(0)}, line {linenum}"
        for linenum, line in enumerate(content.splitlines(), 1)
        for expr in re.finditer(expr_regex, line)
        if is_invalid_expression(expr)
    ]


def parse_file(fname: str | Path, teaching: bool = False) -> Parsed:
    tree = ElementTree.parse(fname)
    raw_cont = util.open_text_file(fname).read()
    comments_removed = replace_commented_xml_with_empty_lines(raw_cont)

    vardecl = {
        v.attrib.get("name", "unknown_var"): (v.text or "")
        for v in tree.iter("variable")
    }
    all_params = {
        v.attrib.get("name", "unknown_var"): (v.text or "")
        for v in tree.iter("parameter")
    }

    propaccess = {
        (match.group(1), match.group(2))
        for match in re.finditer("([a-zA-Z.]+),(\\w+)", comments_removed)
    }
    varuse = {name for expr_type, name in propaccess if expr_type.lower() == "v"}
    used_functions = {
        expr_type + "," + name
        for expr_type, name in propaccess
        if expr_type.lower() == "f"
    }

    # what to do with p params?
    otherpropaccess = {k for k, v in propaccess if k.lower() not in ["v", "f", "p"]}
    otherpropaccess.difference_update(MAGIC_VAR_NAMES)
    calls = {summarize_call(v) for v in tree.iter("builtInMethodParameterList")}
    outputs = {summarize_output(v) for v in tree.iter("output")}
    calls.update(outputs)
    alltags = {summarize_tag(t) for t in tree.iter()}
    loop_data_source_attribs = {n.attrib.get("loopDataSource") for n in tree.iter()}
    loop_data_sources = {
        ls.split(";")[0].lower() for ls in loop_data_source_attribs if ls
    }
    return_names = {
        n.attrib.get("name", "UNNAMED_RETURN").lower() for n in tree.iter("return")
    }
    loop_data_sources.update(return_names)

    jumps = {
        describe_jump(n) for n in tree.iter("method") if n.attrib.get("jumpToXmlFile")
    }

    errors = []

    if not teaching:
        cdata_removed = replace_cdata_with_empty_lines(comments_removed)
        errors.extend(brace_check(cdata_removed))

        no_text_allowed_tags = [
            "sections",
            "section",
            "method",
            "output",
            "outputCommands",
            "builtInMethodParameterList",
        ]
        for notext in no_text_allowed_tags:
            nodes = tree.iter(notext)
            for n in nodes:
                if n and n.text and n.text.strip():
                    errors.append(
                        "Node should not contain text: "
                        + describe_node(n)
                        + " -- "
                        + n.text.strip(),
                    )
        var_passwords = {v for v in vardecl if is_illegal_password(v, vardecl[v])}
        param_passwords = {
            p for p in all_params if is_illegal_password(p, all_params[p])
        }
        passwords = var_passwords | param_passwords
        if passwords:
            errors.append("Passwords contains literal text: " + ",".join(passwords))

        outputresource_errors = check_outputresources(tree.getroot())
        errors.extend(outputresource_errors)

        expr_errors = find_invalid_expressions(comments_removed)
        errors.extend(expr_errors)

    return Parsed(
        var_decl=set(vardecl),
        var_used=varuse,
        alltags=alltags,
        calls=calls,
        jumps=jumps,
        used_functions=used_functions,
        syntax_errors=errors,
        loop_var_decl=loop_data_sources,
        loop_var_use=otherpropaccess,
        full_content=comments_removed,
    )
