#!/usr/bin/python3
"""Select a service from an Ansible playbook catalogue and deploy it locally."""

import argparse
import curses
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

__version__ = "0.1.1"

DEFAULT_REPO = "https://github.com/elestranobaron/wp-package-installator-playbooks"
CACHE = Path.home() / ".cache" / "ansible-temple" / "catalog"
CONFIG = Path.home() / ".config" / "ansible-temple" / "repository"


class Fail(Exception):
    """A condition the program can explain and stop on."""


def option_key(option):
    return option.get("key") or option.get("name") or option.get("id") or "option"


def option_label(option):
    return option.get("label") or option_key(option)


def as_text(value):
    if value is None:
        return ""
    if isinstance(value, bool):
        return "yes" if value else "no"
    return str(value)


def is_yes(value):
    return value.strip().lower() in {"1", "true", "oui", "yes", "vrai"}


def remember(url):
    CONFIG.parent.mkdir(parents=True, exist_ok=True)
    CONFIG.write_text(url.strip() + "\n", encoding="utf-8")


def remembered():
    try:
        return CONFIG.read_text(encoding="utf-8").strip()
    except OSError:
        return ""


def git(args, cwd=None):
    return subprocess.run(
        ["git", *args],
        cwd=cwd,
        text=True,
        capture_output=True,
    )


def fetch(url):
    """Clone or update the catalogue. Return the checkout directory."""
    url = url.strip()
    if not url:
        raise Fail("empty repository address")
    if CACHE.exists() and not (CACHE / ".git").is_dir():
        shutil.rmtree(CACHE)
    CACHE.parent.mkdir(parents=True, exist_ok=True)
    if (CACHE / ".git").is_dir():
        origin = git(["remote", "get-url", "origin"], CACHE)
        if origin.returncode != 0 or origin.stdout.strip() != url:
            shutil.rmtree(CACHE)
        else:
            pulled = git(["pull", "--ff-only"], CACHE)
            if pulled.returncode != 0:
                detail = (pulled.stderr or pulled.stdout).strip()
                raise Fail(detail or "git pull failed")
            return CACHE
    cloned = git(["clone", "--depth", "1", url, str(CACHE)])
    if cloned.returncode != 0:
        detail = (cloned.stderr or cloned.stdout).strip()
        raise Fail(detail or "git clone failed")
    return CACHE


def read_json(path):
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except OSError as exc:
        raise Fail(f"unreadable: {path}: {exc.strerror}") from exc
    except json.JSONDecodeError as exc:
        raise Fail(f"invalid JSON: {path}: {exc}") from exc


def service_infos(root):
    base = root / "playbooks"
    if not base.is_dir():
        return []
    found = []
    for path in sorted(base.glob("*/info.json")):
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            continue
        if isinstance(data, dict):
            data["_folder"] = path.parent.name
            found.append(data)
    return found


def find_info(item, infos):
    ident = item.get("id")
    name = item.get("name")
    for info in infos:
        if ident and (info.get("id") == ident or info.get("_folder") == ident):
            return info
        if name and info.get("name") == name:
            return info
    return {}


def load_catalog(root):
    """Return the services whose playbook file is present."""
    root = Path(root)
    manifest = read_json(root / "manifest.json")
    packages = manifest.get("packages")
    if not isinstance(packages, list):
        raise Fail("manifest.json has no packages list")
    infos = service_infos(root)
    catalog = []
    for item in packages:
        if not isinstance(item, dict):
            continue
        info = find_info(item, infos)
        folder = info.get("_folder") or item.get("id") or "service"
        playbook = item.get("playbook") or info.get("playbook") or f"playbooks/{folder}/main.yml"
        if not (root / playbook).is_file():
            continue
        options = item.get("options")
        if not isinstance(options, list):
            options = info.get("options") if isinstance(info.get("options"), list) else []
        catalog.append({
            "id": item.get("id") or info.get("id") or folder,
            "name": item.get("name") or info.get("name") or folder,
            "category": item.get("category") or info.get("category") or "",
            "description": item.get("description") or info.get("description") or "",
            "playbook": playbook,
            "options": [opt for opt in options if isinstance(opt, dict)],
        })
    if not catalog:
        raise Fail("no usable playbook in this repository")
    return catalog


def clip(text, width):
    text = str(text).replace("\n", " ")
    if width <= 0:
        return ""
    if len(text) <= width:
        return text
    if width == 1:
        return "…"
    return text[: width - 1] + "…"


def write_at(window, y, x, text, attr=0):
    height, width = window.getmaxyx()
    if y < 0 or y >= height or x >= width:
        return
    window.addnstr(y, x, text, width - x - 1, attr)


def row_text(service, width):
    category = service["category"]
    name = service["name"]
    if not category or width < len(category) + 8:
        return clip(name, width)
    room = width - len(category) - 2
    return clip(name, room).ljust(room) + "  " + category


class Screen:
    def __init__(self, window):
        self.window = window
        curses.curs_set(0)
        self.window.keypad(True)
        self.gold = curses.A_NORMAL
        self.dim = curses.A_DIM
        self.alert = curses.A_BOLD
        if curses.has_colors():
            curses.start_color()
            curses.use_default_colors()
            curses.init_pair(1, curses.COLOR_WHITE, -1)
            curses.init_pair(2, curses.COLOR_YELLOW, -1)
            self.gold = curses.color_pair(2)
            self.dim = curses.color_pair(1) | curses.A_DIM
            self.alert = curses.color_pair(2) | curses.A_BOLD

    def size(self):
        return self.window.getmaxyx()

    def line(self, y, x, text, attr=0):
        write_at(self.window, y, x, text, attr)

    def bar(self, y, text, attr):
        _, width = self.size()
        self.line(y, 0, clip(text, width - 1).ljust(width - 1), attr)

    def key(self):
        return self.window.get_wch()


def ask_repository(screen, initial):
    value = initial or DEFAULT_REPO
    curses.curs_set(1)
    try:
        while True:
            screen.window.erase()
            height, width = screen.size()
            screen.bar(0, " ansible-temple", screen.gold | curses.A_BOLD)
            screen.line(2, 2, "Catalogue repository", screen.gold)
            screen.line(3, 2, "playbooks/<id>/info.json, main.yml, and manifest.json at the root", screen.dim)
            screen.bar(5, " " + value, curses.A_REVERSE)
            screen.line(height - 1, 2, "Enter continue    Esc quit", screen.dim)
            screen.window.refresh()
            key = screen.key()
            if key in (27, curses.KEY_EXIT):
                return None
            if key in ("\n", "\r", curses.KEY_ENTER):
                return value.strip()
            if key in (curses.KEY_BACKSPACE, "\b", "\x7f"):
                value = value[:-1]
            elif isinstance(key, str) and key.isprintable():
                value += key
    finally:
        curses.curs_set(0)


def choose_service(screen, catalog, repository):
    cursor = 0
    offset = 0
    while True:
        screen.window.erase()
        height, width = screen.size()
        if height < 8 or width < 40:
            screen.line(0, 0, "Terminal too small", screen.alert)
            screen.window.refresh()
            screen.key()
            return None
        screen.bar(0, f" ansible-temple    {len(catalog)} services", screen.gold | curses.A_BOLD)
        screen.line(1, 2, clip(repository, width - 4), screen.dim)
        top = 3
        body = height - 5
        if cursor < offset:
            offset = cursor
        if cursor >= offset + body:
            offset = cursor - body + 1
        for row in range(body):
            index = offset + row
            if index >= len(catalog):
                break
            text = row_text(catalog[index], width - 1)
            attr = curses.A_REVERSE if index == cursor else screen.gold
            screen.bar(top + row, text, attr)
        current = catalog[cursor]
        screen.line(height - 2, 2, clip(current["description"], width - 4), screen.dim)
        screen.line(height - 1, 2, "Up Down move    Enter open    q quit", screen.dim)
        screen.window.refresh()
        key = screen.key()
        if key in ("q", 27):
            return None
        if key in (curses.KEY_UP, "k"):
            cursor = max(0, cursor - 1)
        elif key in (curses.KEY_DOWN, "j"):
            cursor = min(len(catalog) - 1, cursor + 1)
        elif key in ("\n", "\r", curses.KEY_ENTER):
            return current


def edit_service(screen, service, repository):
    options = service["options"]
    values = [as_text(opt.get("default")) for opt in options]
    cursor = 0
    editing = False
    buffer = ""
    deploy_at = len(options)
    while True:
        screen.window.erase()
        height, width = screen.size()
        screen.bar(0, " ansible-temple", screen.gold | curses.A_BOLD)
        screen.line(2, 2, service["name"], screen.gold | curses.A_BOLD)
        screen.line(3, 2, clip(service["playbook"], width - 4), screen.dim)
        screen.line(4, 2, clip(repository, width - 4), screen.dim)
        top = 6
        body = max(1, height - top - 1)
        start = 0
        if cursor >= body:
            start = cursor - body + 1
        for row in range(body):
            index = start + row
            if index < len(options):
                shown = buffer if editing and index == cursor else values[index]
                if (options[index].get("type") or "text") == "password":
                    shown = "•" * len(shown)
                if shown == "":
                    shown = "—"
                label = option_label(options[index])
                text = f"{label}  {shown}"
                attr = curses.A_REVERSE if index == cursor else screen.gold
                screen.bar(top + row, " " + clip(text, width - 2), attr)
            elif index == deploy_at:
                attr = curses.A_REVERSE | screen.alert if index == cursor else screen.alert
                screen.bar(top + row, " Deploy", attr)
        screen.line(height - 1, 2, "Enter edit or deploy    Esc back", screen.dim)
        screen.window.refresh()
        key = screen.key()
        if editing:
            if key in ("\n", "\r", curses.KEY_ENTER):
                values[cursor] = buffer
                editing = False
            elif key == 27:
                editing = False
            elif key in (curses.KEY_BACKSPACE, "\b", "\x7f"):
                buffer = buffer[:-1]
            elif isinstance(key, str) and key.isprintable():
                buffer += key
            continue
        if key in (27, "q"):
            return None
        if key in (curses.KEY_UP, "k"):
            cursor = max(0, cursor - 1)
        elif key in (curses.KEY_DOWN, "j"):
            cursor = min(deploy_at, cursor + 1)
        elif key in ("\n", "\r", curses.KEY_ENTER, " "):
            if cursor == deploy_at:
                return encode(options, values)
            kind = options[cursor].get("type") or "text"
            if kind == "boolean":
                values[cursor] = "no" if is_yes(values[cursor]) else "yes"
            elif key == " ":
                continue
            else:
                editing = True
                buffer = values[cursor]


def encode(options, values):
    answers = {}
    for option, value in zip(options, values):
        kind = option.get("type") or "text"
        if kind == "number" and value.strip():
            try:
                answers[option_key(option)] = int(value)
                continue
            except ValueError:
                pass
        if kind == "boolean":
            answers[option_key(option)] = is_yes(value)
        else:
            answers[option_key(option)] = value
    return answers


def run_playbook(root, service, answers):
    playbook = Path(root) / service["playbook"]
    if shutil.which("ansible-playbook") is None:
        raise Fail("ansible-playbook is missing. Install the ansible package.")
    payload = dict(answers)
    payload["id"] = service["id"]
    handle = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8")
    try:
        json.dump(payload, handle, ensure_ascii=False)
        handle.close()
        os.chmod(handle.name, 0o600)
        print(f"ansible-temple: {service['name']}")
        print(f"repository: {root}")
        print(f"playbook: {playbook}")
        print()
        completed = subprocess.run(
            ["ansible-playbook", str(playbook), "-e", f"@{handle.name}"],
            cwd=root,
        )
    finally:
        try:
            os.unlink(handle.name)
        except OSError:
            pass
    return completed.returncode


def interact(repository):
    def draw(window):
        screen = Screen(window)
        chosen = repository
        if not chosen:
            chosen = ask_repository(screen, remembered() or DEFAULT_REPO)
            if not chosen:
                return None
        window.erase()
        screen.line(2, 2, "Reading the repository", screen.gold)
        window.refresh()
        root = fetch(chosen)
        remember(chosen)
        catalog = load_catalog(root)
        while True:
            service = choose_service(screen, catalog, chosen)
            if service is None:
                return None
            answers = edit_service(screen, service, chosen)
            if answers is None:
                continue
            return root, service, answers

    return curses.wrapper(draw)


def parse_args(argv):
    parser = argparse.ArgumentParser(
        prog="ansible-temple",
        description="Choose a service from an Ansible playbook catalogue and deploy it on this machine.",
    )
    parser.add_argument("repository", nargs="?", help="catalogue repository URL")
    parser.add_argument("--version", action="version", version=f"ansible-temple {__version__}")
    parser.add_argument("--check-catalog", metavar="DIRECTORY", help=argparse.SUPPRESS)
    return parser.parse_args(argv[1:])


def main(argv):
    args = parse_args(argv)
    if args.check_catalog:
        catalog = load_catalog(args.check_catalog)
        for service in catalog:
            print(f"{service['id']}\t{service['playbook']}")
        return 0
    try:
        outcome = interact(args.repository)
    except Fail as exc:
        print(f"ansible-temple: {exc}", file=sys.stderr)
        return 2
    except curses.error:
        print("ansible-temple: unusable terminal", file=sys.stderr)
        return 2
    if outcome is None:
        return 0
    root, service, answers = outcome
    try:
        return run_playbook(root, service, answers)
    except Fail as exc:
        print(f"ansible-temple: {exc}", file=sys.stderr)
        return 2


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