#!/usr/bin/env python3
"""Fill xp_flight's aircraft-map.json from a local liveries.db.

Optional helper for anyone who already has the livery database that ATCMatch
is built on.  Without it the map is written by hand; with it the mapping is
generated from the same source ATCMatch uses to name aircraft, so the codes on
both sides cannot drift apart.

It writes the map and nothing else.  The map is the small, portable statement
of where those aircraft live on this disk, and it is what someone without the
database gets.  On the machine that *has* the database there is no reason for a
copy: set `liveries_db` in xp_flight's config.json and it reads the source
itself, which is a run of this script that can never be forgotten.

    ./make-aircraft-map.py --db ~/path/to/liveries.db --root ~/X-Plane-12
"""

from __future__ import annotations

import argparse
import json
import sqlite3
import sys
from pathlib import Path


def relative_to_root(acf: Path, root: Path) -> str | None:
    """The X-Plane-relative path for *acf*, seen through symlinks.

    Aircraft often live on another disk and are linked into ``Aircraft/``.
    X-Plane names them by the linked-to path, so a plain ``relative_to`` on the
    real path finds nothing -- the ``Aircraft`` tree has to be walked as the
    sim sees it.
    """

    target = acf.resolve()
    try:
        return str(target.relative_to(root.resolve()))
    except ValueError:
        pass
    aircraft_dir = root / "Aircraft"
    if not aircraft_dir.is_dir():
        return None
    for candidate in aircraft_dir.rglob("*.acf"):
        try:
            if candidate.resolve() == target:
                return str(candidate.relative_to(root))
        except OSError:
            continue
    return None


def _per_livery(db: sqlite3.Connection, code: str, candidates: list[Path],
                root: Path, mapping: dict[str, str],
                conflicts: list[str]) -> int:
    """Map each paint of a family package to its own model.

    Returns how many entries were written.  A paint whose variant matches no
    file, or more than one, is left out rather than pointed at a guess: a
    missing entry stops xp_flight with a clear message, a wrong one flies.
    """

    written = 0
    rows = db.execute(
        "SELECT livery_name, icao_variant FROM livery "
        "WHERE aircraft_code = ? AND icao_variant IS NOT NULL", (code,))
    for livery_name, variant in rows:
        # Exact stem, never a substring: "E190" must not reach out and claim
        # "E190_cargo" as well.
        hits = [c for c in candidates if c.stem.lower() == variant.lower()]
        if len(hits) != 1:
            continue
        # The paint's own name may name a different model in the same folder
        # ("Lineage 1000 D-AWOW" sits next to Lineage.acf but carries the
        # variant E190).  Where name and variant disagree, neither is trusted:
        # the entry is left out and the user decides.
        if any(c.stem.lower() in livery_name.lower()
               for c in candidates if c is not hits[0]):
            conflicts.append(f"{code}/{livery_name}: the name and the variant "
                             f"({variant}) point at different models")
            continue
        rel = relative_to_root(hits[0], root)
        if rel is None:
            continue
        mapping[f"{code}/{livery_name}"] = rel
        written += 1
    return written


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--db", required=True, help="path to liveries.db")
    parser.add_argument("--root", required=True, help="the X-Plane folder")
    parser.add_argument("-o", "--output", default="aircraft-map.json",
                        help="where to write the map (default: %(default)s)")
    args = parser.parse_args(argv)

    root = Path(args.root).expanduser()
    if not (root / "Aircraft").is_dir():
        print(f"{root} does not look like an X-Plane folder "
              f"(no Aircraft/ inside)", file=sys.stderr)
        return 1

    db = sqlite3.connect(f"file:{Path(args.db).expanduser()}?mode=ro", uri=True)
    rows = db.execute(
        "SELECT aircraft_code, liveries_path, icao_type FROM aircraft "
        "ORDER BY aircraft_code").fetchall()

    mapping: dict[str, str] = {
        "_comment": "aircraft_code from the ATCMatch order -> the .acf, "
                    "relative to the X-Plane folder. Generated by "
                    "make-aircraft-map.py; hand edits survive nothing but a "
                    "rerun, so fix the source instead.",
    }
    skipped: list[str] = []
    for code, liveries_path, icao_type in rows:
        acf_dir = Path(liveries_path).expanduser().parent
        candidates = sorted(acf_dir.glob("*.acf"))
        if len(candidates) > 1 and icao_type:
            # More than one model in the folder (a family package): the ICAO
            # type in the file stem is the only non-guessing way to tell them
            # apart, exactly as xp_flight_init resolves it.
            narrowed = [c for c in candidates
                        if icao_type.lower() in c.stem.lower()]
            if len(narrowed) == 1:
                candidates = narrowed
        if not candidates:
            skipped.append(f"{code}: no .acf in {acf_dir}")
            continue
        if len(candidates) > 1:
            # A family package.  The code alone cannot name a model, but each
            # paint carries the variant it belongs to, so the map gets one
            # "code/livery" entry per paint -- and only where the variant
            # matches exactly one file.  Never pick one at random: the wrong
            # aeroplane would load and the run would still look successful.
            done = _per_livery(db, code, candidates, root, mapping, skipped)
            if not done:
                names = ", ".join(c.name for c in candidates)
                skipped.append(
                    f"{code}: {len(candidates)} models in {acf_dir.name} "
                    f"({names}) and no variant tells them apart -- "
                    f"add the entry by hand")
            continue
        rel = relative_to_root(candidates[0], root)
        if rel is None:
            skipped.append(f"{code}: {candidates[0]} is not reachable under {root}")
            continue
        mapping[code] = rel

    out = Path(args.output).expanduser()
    out.write_text(json.dumps(mapping, indent=2, ensure_ascii=False) + "\n",
                   encoding="utf-8")
    written = len(mapping) - 1
    print(f"{written} aircraft written to {out}")
    for line in skipped:
        print(f"  skipped {line}", file=sys.stderr)
    return 0


if __name__ == "__main__":
    sys.exit(main())
