#!/usr/bin/python3
# -*- coding: utf-8 -*-

from __future__ import annotations

import re
import sys
import termios
import tty
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path

SOURCE_EXTENSIONS: tuple[str, ...] = (
    ".tar.gz",
    ".tar.bz2",
    ".tar.xz",
    ".tar.lz",
    ".tar.zst",
    ".tgz",
    ".tbz2",
    ".zip",
    ".7z",
    ".crate",
)
UNSUPPORTED: tuple[str, str] = ("UNSUPPORTED", "UNTESTED")
EMAIL_RE = re.compile(
    r"^[^@\s]+@[^@\s]+\.[^@\s]+"  # standard: user@domain.tld
    r"|"
    r"^.+\s+[Aa][Tt]\s+[^@\s]+\.[^@\s]+"  # semi-obfuscated: user AT domain.tld
    r"|"
    r"^.+\s+[Aa][Tt]\s+.+\s+[Dd][Oo][Tt]\s+.+$"  # full obfuscated: user AT domain DOT tld
)

ANSI: dict[str, str] = {
    "cyan": "\033[96m",
    "green": "\033[92m",
    "red": "\033[91m",
    "yellow": "\033[93m",
    "reset": "\033[0m",
}

PROG: str = Path(sys.argv[0]).name if sys.argv else "sbo-create"


class _Color:  # pylint: disable=too-few-public-methods
    enabled: bool = True


def set_color(enabled: bool) -> None:
    """Enable or disable ANSI color output globally.

    Args:
        enabled: If True, ANSI color codes are applied to output.
    """
    _Color.enabled = enabled


def colorize(text: str, color: str) -> str:
    """Apply ANSI color to text if stdout is a terminal and color is enabled.

    Args:
        text: The string to colorize.
        color: Color name key from the ANSI palette (e.g. 'cyan', 'red').

    Returns:
        The ANSI-wrapped string, or the original text if color is disabled
        or stdout is not a terminal.
    """
    if not _Color.enabled or not sys.stdout.isatty():
        return text
    return f"{ANSI.get(color, '')}{text}{ANSI['reset']}"


def confirm(
    message: str, before_newline: bool = True, after_newline: bool = True
) -> bool:
    """Prompt for yes/no confirmation.

    Handles Ctrl-C (KeyboardInterrupt) and Ctrl-D (EOFError) as a decline
    and exits with code 0.

    Args:
        message: The prompt text shown to the user before '[y/N]'.
        before_newline: If True, print a blank line before the prompt.
        after_newline: If True, print a blank line after the answer.

    Returns:
        True if the user confirmed ('y' or 'yes'), False otherwise.
    """
    if before_newline:
        print()
    try:
        answer: str = input(f"{message} [y/N] ").strip().lower()
    except (KeyboardInterrupt, EOFError) as exc:
        print()
        raise SystemExit(0) from exc
    if after_newline:
        print()
    return answer in ("y", "yes")


def select_option(prompt: str, options: list[str], default: str = "") -> str:
    """Select an option from a list using arrow keys; Enter confirms.

    Left/Up arrow moves to the previous option; Right/Down moves to the next.
    Ctrl-C raises KeyboardInterrupt; Ctrl-D raises EOFError. Falls back to
    plain input() when stdin is not a TTY (e.g. in tests).

    Args:
        prompt: The label displayed before the options.
        options: List of option strings to cycle through.
        default: The option selected by default (highlighted at start).

    Returns:
        The option string chosen by the user.
    """
    idx: int = options.index(default) if default in options else 0

    if not sys.stdin.isatty():
        raw: str = input(f"  {prompt} [{default}]: ").strip()
        return raw or default

    def render() -> None:
        parts: list[str] = [
            colorize(f"[{opt}]", "cyan") if i == idx else f" {opt} "
            for i, opt in enumerate(options)
        ]
        sys.stdout.write(f"\r{prompt}:  {'  '.join(parts)}\033[K")
        sys.stdout.flush()

    fd: int = sys.stdin.fileno()
    old = termios.tcgetattr(fd)
    try:
        tty.setraw(fd)
        render()
        while True:
            ch: str = sys.stdin.read(1)
            if ch in ("\r", "\n"):
                chosen: str = options[idx]
                sys.stdout.write(f"\r{prompt}: {colorize(chosen, 'cyan')}\033[K\r\n")
                sys.stdout.flush()
                return chosen
            if ch == "\x1b":
                nxt: str = sys.stdin.read(1)
                if nxt == "[":
                    arrow: str = sys.stdin.read(1)
                    if arrow in ("C", "B"):  # right / down
                        idx = (idx + 1) % len(options)
                    elif arrow in ("D", "A"):  # left / up
                        idx = (idx - 1) % len(options)
            elif ch == "\x03":
                raise KeyboardInterrupt
            elif ch == "\x04":
                raise EOFError
            render()
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old)


@dataclass
class InfoData:  # pylint: disable=too-many-instance-attributes
    """SlackBuild .info file data.

    Attributes:
        version: Package version string.
        homepage: Upstream project URL.
        download_x86: Space-separated x86 download URLs.
        md5sum_x86: Space-separated MD5 checksums for x86 sources.
        download_x86_64: Space-separated x86_64 (or ARM64) download URLs.
        md5sum_x86_64: Space-separated MD5 checksums for x86_64/ARM64 sources.
        requires: Space-separated list of SBo dependency names.
        arm_support: True if the package targets ARM64 instead of x86_64.
        labels: Ordered tuple of .info field name prefixes used when parsing.
    """

    version: str = ""
    homepage: str = ""
    download_x86: str = ""
    md5sum_x86: str = ""
    download_x86_64: str = ""
    md5sum_x86_64: str = ""
    requires: str = ""
    maintainer: str = ""
    email: str = ""
    arm_support: bool = False
    labels: tuple[str, ...] = field(default_factory=tuple)


@dataclass
class MaintainerData:  # pylint: disable=too-many-instance-attributes
    """Maintainer configuration data loaded from config.conf.

    Attributes:
        name: Maintainer's full name.
        email: Maintainer's email address.
        where_you_live: Maintainer's location (city, country).
        editor: Preferred text editor command.
        editor_options: Extra flags passed to the editor.
        sbo_repo_version: Target Slackware version (e.g. '15.0').
        sbo_repo_path: Absolute path to the local SBo git repository clone.
        git_branch: Working branch name in the local repository.
        git_main_branch: Name of the upstream main branch (default: 'master').
        git_commit_cmd: Git commit command prefix (default: 'git commit -sm').
        git_push_cmd: Git push command (default: 'git push').
    """

    name: str = ""
    email: str = ""
    where_you_live: str = ""
    editor: str = ""
    editor_options: str = ""
    sbo_repo_version: str = "15.0"
    sbo_repo_path: str = ""
    git_branch: str = ""
    git_main_branch: str = "master"
    git_commit_cmd: str = "git commit -sm"
    git_push_cmd: str = "git push"


@dataclass
class AppContext:  # pylint: disable=too-many-instance-attributes
    """Application shared context passed to all handlers.

    Attributes:
        info: Parsed .info file data.
        maintainer: Maintainer configuration data.
        prg_name: Current package name (derived from the working directory).
        template: Selected SlackBuild template type (e.g. 'autotools').
        current_folder: Working directory for the current operation.
        config_path: Directory where config.conf is stored.
        templates_path: Directory containing SlackBuild templates.
        repo_path: Directory containing the local SBo repository cache.
        config_file: Configuration filename (default: 'config.conf').
        year: Current year, used in copyright headers.
        sbo_packages: Cached mapping of package name to category and description.
        sbo_requires: Cached mapping of package name to its direct dependency list.
        sbo_versions: Cached mapping of package name to its version string.
        strip_comments: If True, remove inline comments from generated SlackBuilds.
    """

    info: InfoData = field(default_factory=InfoData)
    maintainer: MaintainerData = field(default_factory=MaintainerData)

    prg_name: str = ""
    template: str = ""
    current_folder: Path = field(default_factory=Path.cwd)
    config_path: Path = field(
        default_factory=lambda: Path(Path.home(), ".config", "sbo-create")
    )
    templates_path: Path = field(
        default_factory=lambda: Path(
            Path.home(), ".local", "share", "sbo-create", "templates"
        )
    )
    repo_path: Path = field(
        default_factory=lambda: Path(
            Path.home(), ".local", "share", "sbo-create", "repo"
        )
    )
    config_file: str = "config.conf"
    year: int = field(default_factory=lambda: date.today().year)
    sbo_packages: dict[str, str] = field(default_factory=dict)
    sbo_requires: dict[str, list[str]] = field(default_factory=dict)
    sbo_versions: dict[str, str] = field(default_factory=dict)
    strip_comments: bool = False


def set_x86_labels(info: InfoData) -> None:
    """Configure info labels for x86/x86_64 architecture.

    Sets arm_support to False and populates labels with the standard
    x86_64 field name prefixes used when reading .info files.

    Args:
        info: The InfoData instance to configure.
    """
    info.arm_support = False
    info.labels = (
        "PRGNAM=",
        "VERSION=",
        "HOMEPAGE=",
        "DOWNLOAD=",
        "MD5SUM=",
        "DOWNLOAD_x86_64=",
        "MD5SUM_x86_64=",
        "REQUIRES=",
        "MAINTAINER=",
        "EMAIL=",
    )


def set_arm_labels(info: InfoData) -> None:
    """Configure info labels for ARM64 architecture.

    Sets arm_support to True and populates labels with ARM64 field name
    prefixes (DOWNLOAD_ARM64, MD5SUM_ARM64) in place of x86_64 variants.

    Args:
        info: The InfoData instance to configure.
    """
    info.arm_support = True
    info.labels = (
        "PRGNAM=",
        "VERSION=",
        "HOMEPAGE=",
        "DOWNLOAD=",
        "MD5SUM=",
        "DOWNLOAD_ARM64=",
        "MD5SUM_ARM64=",
        "REQUIRES=",
        "MAINTAINER=",
        "EMAIL=",
    )
