# ========================= STANDARDS =======================
from typing import NoReturn
from pathlib import Path
import ast
import sys

# ======================= THIRD-PARTIES =====================
from tuikit.textools import transmit as _transmit
from tuikit.textools import Align, wrap_text
from tuikit.listools import format_order
from tuikit.console import underline

# ========================== LOCALS =========================
from .constants import (
    APP,
    APP_COLOR,
    BAD,
    CMDS,
    CURSOR,
    DEFAULTS_PATH,
    DRACE,
    GOOD,
    HOLD,
    INDENT,
    IGNORED_FILES,
    PROMPT,
    SPEED,
    YELLOW,
    color,
)


center = Align().center

__all__ = [
    "APP",
    "APP_COLOR",
    "BAD",
    "CMDS",
    "CURSOR",
    "DEFAULTS_PATH",
    "DRACE",
    "GOOD",
    "HOLD",
    "INDENT",
    "IGNORED_FILES",
    "PROMPT",
    "SPEED",
    "YELLOW",
    "annotate_parents",
    "color",
    "discover_code_files",
    "find_proot",
    "format_order",
    "pc_colored",
    "tolerant_parse_module",
    "transmit",
    "try_to_parse",
    "underline",
]

# Keep re-exported constants marked as used for static analyzers.
_EXPORTS = (
    APP_COLOR,
    CMDS,
    CURSOR,
    DEFAULTS_PATH,
    DRACE,
    GOOD,
    HOLD,
    INDENT,
    PROMPT,
    SPEED,
    YELLOW,
    BAD,
)


def annotate_parents(node: ast.AST, parent=None) -> None:
    node.parent = parent
    for child in ast.iter_child_nodes(node):
        annotate_parents(child, node)


def _next_docstring_state(line: str, active: bool) -> bool:
    stripped = line.strip()
    marker = '"""' if '"""' in stripped else "'''" if "'''" in stripped else ""
    if not marker: return active

    if stripped.count(marker) % 2 == 1: return not active
    return active


def _line_indent(line: str) -> int:
    return len(line) - len(line.lstrip())


def _is_top_level_start(line: str) -> bool:
    blocks = ("def ", "class ", "if ", "for ", "while ", "try", "@")
    if line.lstrip().startswith(blocks): return True
    elif line.lstrip().startswith("#"):
        return any(b in line for b in blocks)
    return False


def _split_top_level_blocks(lines: list[str]) -> list[tuple[list[str], int]]:
    blocks       = []
    buffer       = []
    in_block     = False
    in_docstring = False
    block_indent = 0
    start_lineno = 0

    for idx, line in enumerate(lines):
        in_docstring = _next_docstring_state(line, in_docstring)
        if _is_top_level_start(line) and _line_indent(line) == 0:
            if buffer:
                blocks.append((buffer, start_lineno))
            buffer       = [line]
            in_block     = True
            block_indent = _line_indent(line)
            start_lineno = idx + 1
        elif in_block:
            if (
                line.strip() == ""
                or in_docstring
                or _line_indent(line) > block_indent
            ):
                buffer.append(line)
            else:
                blocks.append((buffer, start_lineno))
                buffer       = [line]
                in_block     = _is_top_level_start(line)
                start_lineno = idx + 1
        else:
            if not buffer:
                start_lineno = idx + 1
            buffer.append(line)

    if buffer: blocks.append((buffer, start_lineno))
    return blocks


def _shift_node_lines(node: ast.AST, offset: int) -> None:
    for subnode in ast.walk(node):
        if hasattr(subnode, 'lineno'):
            subnode.lineno += offset
        if hasattr(subnode, 'end_lineno'):
            subnode.end_lineno += offset


def try_to_parse(source: str) -> ast.Module | None:
    try: tree = ast.parse(source)
    except SyntaxError: return None
    return tree


def tolerant_parse_module(source: list[str] | str,
                          get: bool = False) -> ast.Module:
    if isinstance(source, str): source = source.splitlines()
    tree = try_to_parse("\n".join(source))
    if tree:
        annotate_parents(tree)
        return (tree, []) if get else tree

    nodes   = []
    blocks  = _split_top_level_blocks(source)
    synerrs = []

    for block, lineno_offset in blocks:
        try:
            parsed = ast.parse('\n'.join(block))
            for node in parsed.body:
                _shift_node_lines(node, lineno_offset - 1)
                nodes.append(node)
        except SyntaxError as e:
            lineno = e.lineno + lineno_offset - 1
            try: msg = e.msg[:e.msg.index("(")]
            except ValueError: msg = e.msg
            synerrs.append((lineno, msg))
            continue

    tree = ast.Module(body=nodes, type_ignores=[])
    annotate_parents(tree)
    if get: return tree, synerrs
    return tree


def discover_code_files(path: Path) -> list[Path] | NoReturn:
    supported = [".py"]  # will expand as I learn more langs
    ignored   = [rule for rule in IGNORED_FILES if rule]

    def is_ignored(file: Path) -> bool:
        file_s = str(file)
        for rule in ignored:
            if file.match(rule): return True
            if file.name == rule: return True
            if rule in file_s: return True
        return False

    if path.is_file() and path.suffix in supported:
        if is_ignored(path): return []
        return [path]
    elif path.is_dir():
        sglobs = ["*" + sfx for sfx in supported]
        files  = []
        for sglob in sglobs:
            files.extend(
                file for file in path.rglob(sglob)
                if not is_ignored(file)
            )
        return sorted(files)

    transmit(f"path '{path}' does not exist\n", hue=BAD)
    sys.exit(1)


def find_proot(path: Path) -> str:
    path = Path(path).resolve()
    if path.is_file(): path = path.parent

    markers = {
        ".git", "pyproject.toml", "setup.py", "setup.cfg",
        "requirements.txt", ".pdm.toml", "Pipfile"
    }

    for parent in [path, *path.parents]:
        if any((parent / marker).exists() for marker in markers):
            return str(parent)

    # fallback to current dir if no marker found
    return str(path)


def pc_colored(pc):
    pc_map = {"75": GOOD, "45": YELLOW}
    hue    = BAD

    for thresh in pc_map.keys():
        if pc >= int(thresh):
            hue = pc_map[thresh]; break

    return color(format_order(f"{pc:.2f}") + "%", hue)


def transmit(text: str, hue: str = PROMPT, end: str = "\n",
            _list: bool = False) -> None:
    print("        " if _list else f"{end}{DRACE}", end="")
    text = wrap_text(text, INDENT, inline=True, order=APP)
    speed = 0 if _list else SPEED
    _transmit(text, speed=speed, hold=HOLD, hue=hue)
