#!/usr/bin/env python3
"""Curses front-end for the OnixOS installer."""

import curses
import curses.textpad
import json
import os
import shutil
import subprocess
import sys
import tempfile

VERSION = "0.2.0"
DEFAULTS = {
    "hostname": "onixos",
    "user": "onix",
    "locale": "en_US.UTF-8",
    "timezone": "UTC",
    "packages": "base linux-onix-zen linux-firmware sudo networkmanager onix-base onixos-cli-installer archlinux-keyring chaotic-keyring chaotic-mirrorlist olang syslinux zsh",
}


def profiles_path():
    local = os.path.join(os.path.dirname(os.path.abspath(__file__)), "installer-profiles.json")
    return local if os.path.exists(local) else "/usr/share/onixos-cli-installer/installer-profiles.json"


def load_profiles():
    try:
        with open(profiles_path(), encoding="utf-8") as stream:
            profiles = json.load(stream)
        if not isinstance(profiles, dict) or not profiles:
            raise ValueError("profile configuration is empty")
        return profiles
    except (OSError, ValueError, TypeError):
        return {"groups": {"core": {"label": "Core", "description": "Base system.", "options": {"core": {"label": "Core", "description": "Base system.", "packages": [], "display_manager": ""}}}}}


def state_path():
    return os.path.join(os.path.expanduser("~"), ".config", "onixos-installer", "state.json")


def unattended_profile_path():
    return os.environ.get(
        "ONIXOS_INSTALLER_PROFILE",
        os.path.join(os.path.expanduser("~"), ".config", "onixos-installer", "profile.json"),
    )


def load_unattended_profile(groups):
    path = unattended_profile_path()
    if not os.path.isfile(path):
        return None, None
    try:
        with open(path, encoding="utf-8") as stream:
            profile = json.load(stream)
        group = profile["group"]
        selections = profile["selections"]
        if group not in groups or not isinstance(selections, list) or not selections:
            raise ValueError("invalid group or selections")
        options = groups[group]["options"]
        if any(name not in options for name in selections):
            raise ValueError("unknown selection")
        if groups[group].get("selection") == "single" and len(selections) != 1:
            raise ValueError("this group requires exactly one selection")
        values = dict(DEFAULTS)
        values.update({key: profile[key] for key in ("hostname", "user", "locale", "timezone", "packages") if key in profile})
        values["user_password_hash"] = profile["user_password_hash"]
        values["root_password_hash"] = profile["root_password_hash"]
        for key in ("user_password_hash", "root_password_hash"):
            value = values[key]
            if not isinstance(value, str) or not value.startswith("$") or any(char.isspace() or char == ":" for char in value):
                raise ValueError(f"{key} must contain a password hash")
        if not isinstance(profile["disk"], str) or not profile["disk"]:
            raise ValueError("disk is required")
        return {"disk": profile["disk"], "group": group, "selections": selections, "values": values}, None
    except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
        return None, f"Cannot use unattended profile: {error}"


def load_state():
    try:
        with open(state_path(), encoding="utf-8") as stream:
            state = json.load(stream)
        return state if isinstance(state, dict) and state.get("step") else None
    except (OSError, ValueError):
        return None


def save_state(step, disk, group=None, selections=None, values=None):
    state = {"step": step, "disk": disk}
    if group is not None:
        state["group"] = group
    if selections is not None:
        state["selections"] = selections
    if values is not None:
        state["values"] = values
    path = state_path()
    directory = os.path.dirname(path)
    os.makedirs(directory, mode=0o700, exist_ok=True)
    fd, temporary = tempfile.mkstemp(prefix="state-", dir=directory, text=True)
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as stream:
            json.dump(state, stream, indent=2)
            stream.write("\n")
        os.chmod(temporary, 0o600)
        os.replace(temporary, path)
    finally:
        if os.path.exists(temporary):
            os.unlink(temporary)


def clear_state():
    try:
        os.unlink(state_path())
    except FileNotFoundError:
        pass


def resume(screen, state):
    draw_header(screen, "Resume installation", "A saved installer session was found in the live ISO home directory.")
    width = screen.getmaxyx()[1]
    screen.addnstr(7, 5, f"Disk: {state.get('disk', '-')}", width - 10)
    screen.addnstr(8, 5, "Continue from the last saved step?", width - 10, curses.A_BOLD)
    screen.addnstr(10, 5, "Enter: resume    n: start over    q: quit", width - 10, curses.A_DIM)
    screen.refresh()
    while True:
        key = screen.getch()
        if key in (ord("q"), 27):
            return None
        if key in (ord("n"), ord("N")):
            clear_state()
            return False
        if key in (curses.KEY_ENTER, 10, 13):
            return True


def disks():
    try:
        output = subprocess.check_output(
            ["lsblk", "-dpno", "NAME,SIZE,TYPE,MODEL"], text=True
        )
    except (OSError, subprocess.CalledProcessError):
        return []
    result = []
    for line in output.splitlines():
        fields = line.split(None, 3)
        if len(fields) >= 3 and fields[2] == "disk":
            result.append((fields[0], fields[1], fields[3] if len(fields) > 3 else "-"))
    return result


def draw_header(screen, title, subtitle=""):
    screen.erase()
    height, width = screen.getmaxyx()
    screen.attron(curses.color_pair(1) | curses.A_BOLD)
    screen.addnstr(1, 2, " ONIXOS CLI INSTALLER ", width - 4)
    screen.attroff(curses.color_pair(1) | curses.A_BOLD)
    screen.addnstr(3, 3, title, width - 6, curses.A_BOLD)
    if subtitle:
        screen.addnstr(4, 3, subtitle, width - 6, curses.A_DIM)
    screen.addnstr(height - 2, 3, "↑↓ navigate   Enter select   Esc back   q quit", width - 6, curses.A_DIM)


def message(screen, title, body, error=False):
    draw_header(screen, title)
    height, width = screen.getmaxyx()
    color = curses.color_pair(3) if error else curses.color_pair(2)
    for index, line in enumerate(body.splitlines()):
        screen.addnstr(7 + index, 5, line, width - 10, color)
    screen.addnstr(height - 4, 5, "Press any key to continue", width - 10, curses.A_BOLD)
    screen.refresh()
    screen.getch()


def select_disk(screen):
    items = disks()
    if not items:
        message(screen, "No disks found", "lsblk could not find an installable disk.", True)
        return None
    selected = 0
    while True:
        draw_header(screen, "Select installation disk", "All data on the selected disk will be erased.")
        height, width = screen.getmaxyx()
        screen.addnstr(6, 5, "DEVICE        SIZE       MODEL", width - 10, curses.A_BOLD)
        for index, (name, size, model) in enumerate(items):
            if 7 + index >= height - 4:
                break
            attr = curses.color_pair(1) if index == selected else 0
            screen.addnstr(7 + index, 4, f"  {name:<12} {size:<10} {model}", width - 8, attr)
        screen.refresh()
        key = screen.getch()
        if key in (ord("q"), 27):
            return None
        if key in (curses.KEY_UP, ord("k")):
            selected = (selected - 1) % len(items)
        elif key in (curses.KEY_DOWN, ord("j")):
            selected = (selected + 1) % len(items)
        elif key in (curses.KEY_ENTER, 10, 13):
            return items[selected][0]


def select_group(screen, groups):
    options = [(name, group["label"], group["description"]) for name, group in groups.items()]
    selected = 0
    while True:
        draw_header(screen, "Select installation group", "Choose Core, Server, or Desktop.")
        height, width = screen.getmaxyx()
        for index, (_, label, description) in enumerate(options):
            row = 7 + index * 2
            attr = curses.color_pair(1) if index == selected else 0
            screen.addnstr(row, 5, f"  {label}", width - 10, attr)
            screen.addnstr(row + 1, 9, description, width - 14, curses.A_DIM)
        screen.refresh()
        key = screen.getch()
        if key in (ord("q"), 27):
            return None
        if key in (curses.KEY_UP, ord("k")):
            selected = (selected - 1) % len(options)
        elif key in (curses.KEY_DOWN, ord("j")):
            selected = (selected + 1) % len(options)
        elif key in (curses.KEY_ENTER, 10, 13):
            return options[selected][0]


def select_group_options(screen, group):
    options = list(group["options"].items())
    selected = 0
    checked = set()
    multi = group.get("selection") == "multi"
    while True:
        title = group["label"]
        hint = "Space: select/unselect    Enter: continue" if multi else "Choose one option"
        draw_header(screen, f"Select {title} options", hint)
        width = screen.getmaxyx()[1]
        for index, (name, option) in enumerate(options):
            marker = "[x]" if name in checked else "[ ]"
            attr = curses.color_pair(1) if index == selected else 0
            screen.addnstr(7 + index * 2, 5, f"{marker} {option['label']}", width - 10, attr)
            screen.addnstr(8 + index * 2, 9, option["description"], width - 14, curses.A_DIM)
        screen.refresh()
        key = screen.getch()
        if key in (ord("q"), 27):
            return None
        if key in (curses.KEY_UP, ord("k")):
            selected = (selected - 1) % len(options)
        elif key in (curses.KEY_DOWN, ord("j")):
            selected = (selected + 1) % len(options)
        elif multi and key == ord(" "):
            name = options[selected][0]
            if name in checked:
                checked.remove(name)
            else:
                checked.add(name)
        elif key in (curses.KEY_ENTER, 10, 13):
            if multi:
                if checked:
                    return sorted(checked)
            else:
                return [options[selected][0]]


def edit_value(screen, label, value, description):
    draw_header(screen, "Installation settings", description)
    height, width = screen.getmaxyx()
    screen.addnstr(8, 5, label, width - 10, curses.A_BOLD)
    screen.addnstr(10, 5, "> ", width - 10, curses.color_pair(2))
    screen.refresh()
    window = curses.newwin(1, min(width - 12, 60), 10, 8)
    window.addstr(value)
    box = curses.textpad.Textbox(window)
    curses.curs_set(1)
    result = box.edit().strip()
    curses.curs_set(0)
    return result or value


def password_value(screen, label, description):
    draw_header(screen, "Password settings", description)
    width = screen.getmaxyx()[1]
    screen.addnstr(8, 5, label, width - 10, curses.A_BOLD)
    screen.addnstr(10, 5, "> ", width - 10, curses.color_pair(2))
    screen.refresh()
    curses.noecho()
    curses.curs_set(1)
    try:
        password = screen.getstr(10, 8, min(width - 12, 60)).decode(errors="replace")
    finally:
        curses.curs_set(0)
        curses.echo()
    return password


def password_settings(screen, state):
    if state.get("user_password_hash") and state.get("root_password_hash"):
        draw_header(screen, "Password settings", "Password hashes are already saved for this session.")
        screen.addnstr(8, 5, "Press Enter to keep them, or u to set new passwords.", screen.getmaxyx()[1] - 10)
        screen.refresh()
        if screen.getch() not in (ord("u"), ord("U")):
            return state["user_password_hash"], state["root_password_hash"]
    while True:
        user_password = password_value(screen, "User password", "The password for the first user (input is hidden).")
        root_password = password_value(screen, "Root password", "The root password (input is hidden).")
        user_confirm = password_value(screen, "Confirm user password", "Enter the user password again.")
        root_confirm = password_value(screen, "Confirm root password", "Enter the root password again.")
        if not user_password or not root_password:
            message(screen, "Password required", "User and root passwords cannot be empty.", True)
        elif user_password != user_confirm or root_password != root_confirm:
            message(screen, "Passwords do not match", "Please enter both passwords again.", True)
        else:
            try:
                return hash_password(user_password), hash_password(root_password)
            except (OSError, subprocess.CalledProcessError):
                message(screen, "Password hashing failed", "openssl is required to securely hash passwords.", True)


def hash_password(password):
    result = subprocess.run(
        ["openssl", "passwd", "-6", "-stdin"],
        input=password + "\n",
        text=True,
        capture_output=True,
        check=True,
    )
    return result.stdout.strip()


def settings(screen, initial=None):
    values = dict(DEFAULTS)
    if initial:
        values.update({key: value for key, value in initial.items() if key in values})
    fields = [
        ("hostname", "Hostname", "Name used by the installed system."),
        ("user", "First user", "A wheel-group user will be created."),
        ("locale", "Locale", "Locale generated in the installed system."),
        ("timezone", "Timezone", "Example: Europe/Istanbul or UTC."),
        ("packages", "Core packages", "Space-separated packages installed by pacstrap."),
    ]
    for key, label, description in fields:
        values[key] = edit_value(screen, label, values[key], description)
    return values


def review(screen, disk, group, selections, values, groups):
    while True:
        draw_header(screen, "Review installation", "Press Enter to begin or Esc to go back.")
        height, width = screen.getmaxyx()
        visible_values = [(key.title(), value) for key, value in values.items() if not key.endswith("_hash")]
        selected_labels = []
        for name in selections:
            selected_labels.append(groups[group]["options"][name]["label"])
        rows = [("Disk", disk), ("Group", groups[group]["label"]), ("Options", ", ".join(selected_labels)), *visible_values]
        for index, (label, value) in enumerate(rows):
            screen.addnstr(7 + index, 5, f"{label:<14} {value}", width - 10)
        screen.addnstr(16, 5, "WARNING: the selected disk will be wiped.", width - 10, curses.color_pair(3) | curses.A_BOLD)
        screen.refresh()
        key = screen.getch()
        if key in (27, ord("q")):
            return False
        if key in (curses.KEY_ENTER, 10, 13):
            return True


def run_install(screen, disk, group, selections, values, unattended=False):
    backend = os.path.join(os.path.dirname(os.path.abspath(__file__)), "onixos-installer")
    if not os.path.exists(backend):
        backend = "/usr/bin/onixos-installer-cli"
    command = [backend, "--disk", disk, "--group", group, "--selections", ",".join(selections), "--yes"]
    for key in ("hostname", "user", "locale", "timezone"):
        command += [f"--{key}", values[key]]
    command += ["--packages", values["packages"]]
    command += ["--user-password-hash", values["user_password_hash"]]
    command += ["--root-password-hash", values["root_password_hash"]]
    curses.def_prog_mode()
    curses.endwin()
    print("Starting OnixOS installation...\n")
    result = subprocess.run(command)
    print("\nInstallation finished with status %d." % result.returncode)
    if not unattended:
        print("Press Enter to return.")
        input()
    return result.returncode


def app(screen):
    curses.curs_set(0)
    curses.start_color()
    curses.use_default_colors()
    curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_CYAN)
    curses.init_pair(2, curses.COLOR_GREEN, -1)
    curses.init_pair(3, curses.COLOR_RED, -1)
    if shutil.which("lsblk") is None:
        message(screen, "Missing dependency", "lsblk is required to show disks.", True)
        return 1
    config = load_profiles()
    groups = config.get("groups", {})
    if not groups:
        message(screen, "Profile configuration error", "No installation groups are configured.", True)
        return 1
    unattended, profile_error = load_unattended_profile(groups)
    if profile_error:
        message(screen, "Unattended profile error", profile_error, True)
        return 1
    if unattended:
        save_state("installing", unattended["disk"], unattended["group"], unattended["selections"], unattended["values"])
        result = run_install(screen, unattended["disk"], unattended["group"], unattended["selections"], unattended["values"], True)
        if result == 0:
            clear_state()
            try:
                os.unlink(unattended_profile_path())
            except FileNotFoundError:
                pass
        return result
    draw_header(screen, "Welcome", "Interactive UEFI installer for OnixOS")
    screen.addnstr(7, 5, "This wizard will erase a disk and install a bootable", 70)
    screen.addnstr(8, 5, "OnixOS system. Review every setting before continuing.", 70)
    screen.addnstr(11, 5, "Press Enter to start, or q to quit.", 70, curses.A_BOLD)
    screen.refresh()
    if screen.getch() in (ord("q"), 27):
        return 1
    saved = load_state()
    decision = resume(screen, saved) if saved else False
    if decision is None:
        return 1
    if decision:
        disk = saved.get("disk")
        group = saved.get("group")
        selections = saved.get("selections") or []
        values = saved.get("values") or {}
        step = saved.get("step")
    else:
        disk = group = None
        selections = []
        values = {}
        step = "disk"
    if step == "disk" or not disk:
        disk = select_disk(screen)
        if not disk:
            return 1
        save_state("group", disk)
        step = "group"
    if step == "group" or group not in groups:
        group = select_group(screen, groups)
        if group is None:
            return 1
        save_state("options", disk, group)
        step = "options"
    if step == "options" or not selections or any(name not in groups[group]["options"] for name in selections):
        selections = select_group_options(screen, groups[group])
        if selections is None:
            return 1
        save_state("settings", disk, group, selections)
        step = "settings"
    if step == "settings":
        values = settings(screen, values)
        step = "passwords"
    if step == "passwords":
        values["user_password_hash"], values["root_password_hash"] = password_settings(screen, values)
        save_state("review", disk, group, selections, values)
    if not review(screen, disk, group, selections, values, groups):
        return 1
    save_state("installing", disk, group, selections, values)
    result = run_install(screen, disk, group, selections, values)
    if result == 0:
        clear_state()
    else:
        save_state("install_failed", disk, group, selections, values)
    return result


def main():
    if len(sys.argv) > 1:
        if len(sys.argv) == 3 and sys.argv[1] == "--unattended":
            os.environ["ONIXOS_INSTALLER_PROFILE"] = os.path.abspath(sys.argv[2])
        else:
            print("error: run onixos-installer without command-line options", file=sys.stderr)
            print("the installation choices are available in the Curses wizard", file=sys.stderr)
            return 2
    if os.geteuid() != 0:
        print("error: run this installer as root", file=sys.stderr)
        return 1
    return curses.wrapper(app)


if __name__ == "__main__":
    raise SystemExit(main())
