#!/usr/bin/env python3
"""Find the livery file that breaks aircraft creation in dcs-mcp.

    python find_broken_livery.py
    python find_broken_livery.py "C:\\Program Files\\Eagle Dynamics\\DCS World"
    python find_broken_livery.py <any folder that contains liveries>

WHAT THIS IS FOR

If creating any aircraft fails with

    could not convert string to float: '.'

then one livery file somewhere on your machine has a malformed number in it,
and the error does not say which. This script finds it.

Pure standard library -- no dcs-mcp, no pydcs, no DCS running. Python 3.8+.
It only reads files; it changes nothing.

ARGUMENTS ARE OPTIONAL, AND YOU CANNOT GET THEM WRONG

With no arguments it locates your DCS installation and your Saved Games folder
by itself. Any path you DO pass is searched every plausible way -- as a DCS
installation, and as a livery tree in its own right -- so the installation
folder, a Saved Games folder, and a Liveries folder all work.

That is deliberate. An earlier version used the argument ONLY as an
installation path while its own help text told users with a non-default setup
to pass their Saved Games folder. Following those instructions produced a scan
that searched nothing relevant, reported "no problems found", and sent the user
away -- a correct diagnosis made to look wrong by the tool meant to give it.

IT ALWAYS SAYS WHERE IT LOOKED, BEFORE IT SAYS WHAT IT FOUND

Every location is printed with the number of files read, and missing ones are
marked. A scanner that reports "nothing found" without saying where it searched
is silence that looks like data, and a diagnostic is the last place that can be
afforded: "Saved Games\\DCS\\Liveries -- not found" tells the user something
real, while a bare "no problems" may convince them their problem is elsewhere
when it is not.

WHAT IT LOOKS FOR, precisely

Not "a dot". `.1` is a perfectly good number and parses fine. The failure is a
dot that starts a number and is not followed by any digit -- for example

    {"num_black", DECAL, "num_black", .};      <-- a bare dot as a value

which the parser reads as the number ".", and "." is not a number. Two dots in
a row (`1.2.3`) produce a different, harmless error, so this script reports
only the case that actually stops you.
"""
from __future__ import annotations

import os
import sys
import zipfile

NUMSTART = set("0123456789.-")
NUMBODY = set("0123456789.eE-")


def bad_number_positions(text: str):
    """Yield (line, col, token) for every number token that is not a number.

    Mirrors the parser's own scanner: a token beginning with a digit, '-' or
    '.' is accumulated over [0-9.eE-] and then converted. We report only what
    float() would refuse, so this does not flag `.1`, `1.`, or `1e-3`.
    """
    i, n = 0, len(text)
    line, col = 1, 1
    in_str = None
    while i < n:
        c = text[i]
        if in_str:
            if c == in_str and text[i - 1] != "\\":
                in_str = None
        elif c in ('"', "'"):
            in_str = c
        elif c == "-" and i + 1 < n and text[i + 1] == "-":
            while i < n and text[i] != "\n":                  # a Lua comment
                i += 1
            line, col = line + 1, 1
            i += 1
            continue
        elif c in NUMSTART:
            prev = text[i - 1] if i else " "
            if not (prev.isalnum() or prev == "_"):           # not mid-identifier
                j, tok = i, ""
                while j < n and text[j] in NUMBODY:
                    tok += text[j]
                    j += 1
                if tok and tok not in ("-",):
                    try:
                        float(tok)
                    except ValueError:
                        yield line, col, tok
                    i, col = j, col + (j - i)
                    continue
        if c == "\n":
            line, col = line + 1, 1
        else:
            col += 1
        i += 1


def check_bytes(raw: bytes, where: str, out: list) -> None:
    try:
        text = raw.decode("utf-8", "replace")
    except Exception:                                          # noqa: BLE001
        return
    for ln, cl, tok in bad_number_positions(text):
        out.append((where, ln, cl, tok))


def walk(root: str, out: list, seen: list) -> None:
    if not root or not os.path.isdir(root):
        return
    for dirpath, _dirs, files in os.walk(root):
        for fn in files:
            low = fn.lower()
            p = os.path.join(dirpath, fn)
            if low.endswith(".lua"):
                seen.append(p)
                try:
                    with open(p, "rb") as fh:
                        check_bytes(fh.read(), p, out)
                except OSError:
                    pass
            elif low.endswith(".zip"):
                seen.append(p)
                try:
                    with zipfile.ZipFile(p) as z:
                        for nm in z.namelist():
                            if nm.lower().endswith(".lua"):
                                check_bytes(z.read(nm), f"{p} :: {nm}", out)
                except Exception:                              # noqa: BLE001
                    pass


def livery_roots(paths: list) -> list:
    """Every location worth searching: the given paths, plus autodetection.

    Each supplied path is treated BOTH as a DCS installation and as a livery
    tree in its own right, so a user who passes the "wrong" kind of path still
    gets a correct answer. See the module docstring for why that matters.
    """
    roots: list = []

    def add(p: str) -> None:
        if p and p not in roots:
            roots.append(p)

    supplied = [p.rstrip("\\/") for p in paths]
    if not supplied:
        for guess in (
            r"C:\Program Files\Eagle Dynamics\DCS World",
            r"C:\Program Files\Eagle Dynamics\DCS World OpenBeta",
            r"C:\Program Files\Eagle Dynamics\DCS World Server",
        ):
            if os.path.isdir(guess):
                supplied.append(guess)

    for base in supplied:
        add(os.path.join(base, "Bazar", "Liveries"))
        add(os.path.join(base, "CoreMods"))
        add(os.path.join(base, "Mods"))
        add(os.path.join(base, "Liveries"))
        add(base)                                    # and the folder itself

    # Saved Games is ALWAYS searched, whatever was passed -- it is where users
    # install their own liveries, and the likeliest home of a broken one.
    sg = os.path.join(os.path.expanduser("~"), "Saved Games")
    if os.path.isdir(sg):
        for entry in sorted(os.listdir(sg)):
            if entry.upper().startswith("DCS"):
                add(os.path.join(sg, entry, "Liveries"))
    return roots


def main() -> int:
    args = [a for a in sys.argv[1:] if a.strip()]
    roots = livery_roots(args)

    print("=" * 70)
    print("SEARCHED THESE LOCATIONS")
    print("=" * 70)

    out: list = []
    total = 0
    any_real = False
    saved_games_had_files = False
    for r in roots:
        if not os.path.isdir(r):
            print(f"    not found   {r}")
            continue
        any_real = True
        seen: list = []
        walk(r, out, seen)
        total += len(seen)
        if seen and "saved games" in r.lower():
            saved_games_had_files = True
        print(f"  {len(seen):5} files   {r}")
    print()

    if not any_real:
        print("NONE of those locations exist, so nothing was searched.")
        print("This is NOT a clean result.\n")
        print("Pass the folder your liveries are in, for example:")
        print(r'   python find_broken_livery.py "C:\Program Files\Eagle Dynamics\DCS World"')
        return 2

    if total == 0:
        print("Those locations exist but hold no .lua or .zip files, so nothing")
        print("was actually checked. This is NOT a clean result -- point the")
        print("script at the folder your liveries are in.")
        return 2

    # DEDUPLICATE. The roots deliberately overlap -- a supplied path is
    # searched as <base>/Bazar/Liveries AND as <base> itself -- so one file can
    # be read twice. Without this, a single bad livery is reported as "FOUND 2
    # malformed number(s)" and the user goes looking for a second one that does
    # not exist. Found by running the copy actually served from the site.
    seen_hits = set()
    deduped = []
    for hit in out:
        if hit not in seen_hits:
            seen_hits.add(hit)
            deduped.append(hit)
    out = deduped

    print(f"Read {total} file(s) in total.\n")

    if not out:
        print("No malformed numbers found in any of them.")
        print()
        # A "clean" result is only as good as the search behind it. If no
        # Saved Games livery tree turned up anything, say so plainly rather
        # than letting the absence pass as evidence -- that is the likeliest
        # home of a broken livery, and a non-default Saved Games location is
        # the one thing this script cannot autodetect.
        if not saved_games_had_files:
            print("BUT NOTE: no livery files were found under Saved Games, which")
            print("is where downloaded liveries normally live. If yours are")
            print("somewhere else, this search has not seen them. You can pass")
            print("more than one folder:")
            print()
            print(r'   python find_broken_livery.py "C:\...\DCS World" "D:\My DCS\Liveries"')
            print()
        print("If aircraft creation still fails, the cause may be elsewhere. Send")
        print("the WHOLE of this output back, including the list of locations")
        print("above, so we can see where the search did and did not reach.")
        return 0

    print(f"FOUND {len(out)} malformed number(s). "
          f"The first is almost certainly your problem:\n")
    for where, ln, cl, tok in out:
        print(f"  {where}")
        print(f"      line {ln}, column {cl}:  {tok!r} is not a number\n")
    print("To fix: move that livery's FOLDER out of the Liveries directory")
    print("(do not just rename the file), then restart Claude Desktop.")
    return 1


if __name__ == "__main__":
    raise SystemExit(main())
