"""setup_py_colors.py — turn on colour highlighting for .py files in vim and nano.

    python setup_py_colors.py              # set it up
    python setup_py_colors.py --dry-run    # show what would change, write nothing
    python setup_py_colors.py --undo       # remove what this script added

It edits two files in your home folder, ~/.vimrc and ~/.nanorc, and only the part
between its own markers. Anything else in those files is left alone, and a .bak
copy is made the first time each file is changed. Running it twice changes nothing.

nano shows colour only if it can find a syntax file for Python. This script looks
for the ones your system already has. If there are none, it writes a small one to
~/.nano/python.nanorc.

How it works, in one paragraph: both editors read a settings file from your home
folder when they start. This script does not replace those files. It adds one block
to each, fenced between two marker lines, so it can find that block again later and
update or remove it without touching anything you wrote yourself.
"""
import argparse
import shutil
import subprocess
from pathlib import Path

# The fence around our block. Everything between these two lines belongs to this
# script; everything outside is the user's own settings and is never touched.
MARK = ">>> py-colors (setup_py_colors.py) >>>"
END_MARK = "<<< py-colors <<<"

# A vim comment starts with a double quote, a nano one with a hash. The marker lines
# have to be comments in the file they live in, so the character depends on the file.
# Using the wrong one makes the editor complain on every start (vim: E492).
COMMENT = {".vimrc": '"', ".nanorc": "#"}

# What we add to ~/.vimrc. "syntax on" is the line that turns colour on; the rest
# makes Python files easier to read and matches the usual Python style (4 spaces,
# and a marker at column 88 so you can see when a line is getting long).
VIMRC = """syntax on
filetype plugin indent on
set number
set tabstop=4 shiftwidth=4 expandtab
set background=dark
autocmd FileType python setlocal colorcolumn=88
"""

# nano's settings, added to ~/.nanorc. The include line that actually brings in the
# colours is added later, once we know where this machine keeps its syntax files.
NANORC_SETTINGS = """set linenumbers
set tabstospaces
set tabsize 4
set constantshow
"""


def nano_dirs() -> list[str]:
    """List the folders on this machine that hold nano's syntax files.

    nano does not colour anything by itself: it reads a "syntax file" per language,
    and those files live in a share folder next to the nano program. The first place
    we look is worked out from nano itself (…/bin/nano → …/share/nano), which is what
    finds them on Git Bash for Windows and on Homebrew. After that come the usual
    Linux and macOS locations. Folders that do not exist are dropped, so the caller
    gets only real ones, in the order they should be tried.
    """
    found = []
    exe = shutil.which("nano")                       # full path to nano, or None
    if exe:
        # resolve() follows a symlink such as /usr/local/bin/nano → /usr/bin/nano,
        # so that parents[1] is the folder that really holds bin/ and share/.
        found.append(str(Path(exe).resolve().parents[1] / "share" / "nano"))
    found += [
        "/usr/share/nano",                           # most Linux systems
        "/usr/local/share/nano",                     # Homebrew on Intel Macs
        "/opt/homebrew/share/nano",                  # Homebrew on Apple Silicon
        "/usr/share/nano-syntax-highlighting",       # some distributions' extra set
    ]
    seen, out = set(), []
    for d in found:
        if d not in seen and Path(d).is_dir():       # keep real folders, no repeats
            seen.add(d)
            out.append(d)
    return out


# A small Python syntax file of our own, used only on a machine whose nano ships
# none. nano wraps every pattern in double quotes, so a rule for double-quoted
# strings needs a double quote inside a double quote. Writing that literally here
# would end this Python string early, so the quote is put in at runtime instead.
DQ = '"'
FALLBACK_NANORC = "\n".join([
    "## Minimal Python highlighting, written by setup_py_colors.py",
    "## Used only when the system has no python.nanorc of its own.",
    r'syntax "python" "\.py$"',                      # these rules apply to .py files
    r'comment "#"',                                  # what Ctrl+/ comments out
    "",
    # Keywords, split over three lines only to keep each line readable.
    r'color brightcyan "\<(and|as|assert|async|await|break|class|continue|def|del|elif|else)\>"',
    r'color brightcyan "\<(except|finally|for|from|global|if|import|in|is|lambda|nonlocal)\>"',
    r'color brightcyan "\<(not|or|pass|raise|return|try|while|with|yield)\>"',
    r'color brightblue "def [0-9A-Za-z_]+" "class [0-9A-Za-z_]+"',   # the name you define
    r'color brightmagenta "\<(False|None|True|self|cls)\>"',
    r'color brightmagenta "\<[0-9]+(\.[0-9]+)?\>"',  # whole numbers and decimals
    r"""color brightgreen "'[^']*'" """.rstrip(),    # 'single-quoted strings'
    f'color brightgreen {DQ}{DQ}[^{DQ}]*{DQ}{DQ}',   # "double-quoted strings"
    r'color brightred "(^|[[:blank:]])#.*"',         # comments
    r'color ,red "[[:space:]]+$"',                   # spaces left at the end of a line
    "",
])


def home() -> Path:
    """The user's home folder, where both editors look for their settings."""
    return Path.home()


def read(path: Path) -> str:
    """Return a file's text, or an empty string when the file does not exist yet."""
    return path.read_text(encoding="utf-8") if path.exists() else ""


def block(body: str, hash_: str) -> str:
    """Wrap our settings in the two marker lines, commented for that file's editor.

    `hash_` is the comment character: a double quote for vim, a hash for nano.
    """
    return f"{hash_} {MARK}\n{body.rstrip()}\n{hash_} {END_MARK}\n"


def apply(path: Path, body: str, dry_run: bool, undo: bool) -> str:
    """Add, update or remove this script's block in one settings file.

    The file is treated as three parts: whatever comes before our block, our block,
    and whatever comes after. Only the middle part is ours to change, so the user's
    own settings survive an update and an undo. Returns a line to print, saying what
    happened, and writes nothing at all when `dry_run` is set.
    """
    hash_ = COMMENT[path.name]                       # comment character for this file
    start, end = f"{hash_} {MARK}", f"{hash_} {END_MARK}"
    old = read(path)

    # Cut out an existing block, so a re-run replaces it instead of adding a second.
    if start in old and end in old:
        head, rest = old.split(start, 1)
        _, tail = rest.split(end, 1)
        stripped = head + tail.lstrip("\n")
    else:
        stripped = old

    # --undo stops at the cut. Otherwise put our block back on the end, leaving a
    # blank line after the user's own settings when there are any.
    new = stripped if undo else (stripped.rstrip("\n") + "\n\n" if stripped.strip() else "") + block(body, hash_)

    if new == old:                                   # a second run has nothing to do
        return f"already correct: {path}"
    if dry_run:
        return f"would write: {path}"

    path.parent.mkdir(parents=True, exist_ok=True)
    # Keep one backup of the file as it was before this script first changed it.
    if old and not path.with_suffix(path.suffix + ".bak").exists():
        shutil.copy2(path, path.with_suffix(path.suffix + ".bak"))
    # newline="\n": both editors expect Unix line endings, including on Windows.
    path.write_text(new, encoding="utf-8", newline="\n")
    return f"{'cleaned' if undo else 'wrote'}: {path}"


def nano_includes(dry_run: bool) -> tuple[str, list[str]]:
    """Build the nano settings, and say where the Python colours came from.

    Best case, this machine already has a python.nanorc and we simply include the
    whole folder. Otherwise we write our own small rule set (see FALLBACK_NANORC)
    and include that, alongside any other syntax files the machine does have.

    Returns the text for ~/.nanorc, plus notes for the user about what was chosen.
    """
    notes = []
    found = [d for d in nano_dirs() if list(Path(d).glob("*.nanorc"))]

    for d in found:
        if (Path(d) / "python.nanorc").exists():
            notes.append(f"using the system's Python colours: {d}/python.nanorc")
            # as_posix() gives forward slashes. nano reads a backslash as an escape
            # character, so a Windows path with backslashes would never load.
            return NANORC_SETTINGS + f'include "{Path(d).as_posix()}/*.nanorc"\n', notes

    if found:
        notes.append(f"syntax files found in {found[0]}, but none for Python")

    # No Python rules on this machine: ship our own.
    target = home() / ".nano" / "python.nanorc"
    notes.append(f"{'would write' if dry_run else 'wrote'} our own Python colours: {target}")
    if not dry_run:
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(FALLBACK_NANORC, encoding="utf-8", newline="\n")

    # Keep the machine's other languages working, then add ours.
    includes = "".join(f'include "{Path(d).as_posix()}/*.nanorc"\n' for d in found)
    return NANORC_SETTINGS + includes + f'include "{target.as_posix()}"\n', notes


def version(cmd: str) -> str:
    """Return one line naming the installed version of vim or nano.

    Only for the report at the top of the output: an editor that is missing is worth
    telling the user about, but it does not stop the other one being set up.
    """
    exe = shutil.which(cmd)
    if not exe:
        return f"{cmd}: not installed"
    try:
        out = subprocess.run([exe, "--version"], capture_output=True, text=True, timeout=10).stdout
        return f"{cmd}: {out.splitlines()[0].strip()}"
    except Exception as err:            # an odd build should not stop the setup
        return f"{cmd}: found at {exe} ({err})"


def main():
    """Read the options, change the two settings files, and report what happened."""
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--dry-run", action="store_true", help="show what would change, write nothing")
    ap.add_argument("--undo", action="store_true", help="remove the block this script added")
    args = ap.parse_args()

    print(version("vim"))
    print(version("nano"))

    print(apply(home() / ".vimrc", VIMRC, args.dry_run, args.undo))

    if args.undo:
        # Nothing to build: apply() only needs to find the block and cut it out.
        print(apply(home() / ".nanorc", "", args.dry_run, True))
    else:
        body, notes = nano_includes(args.dry_run)
        for n in notes:
            print(" ", n)
        print(apply(home() / ".nanorc", body, args.dry_run, False))

    print("\nOpen a .py file to check:  vim setup_py_colors.py    nano setup_py_colors.py")
    if not args.undo:
        print("No colour in nano? Your nano may be too old for syntax files: nano --version")


if __name__ == "__main__":      # only run when started directly, not when imported
    main()
