#!/usr/bin/env python3
"""Terminal UI for common Git workflows.

This module provides a single-file curses application that helps users run
common Git commands from a keyboard-driven terminal interface.
"""

from __future__ import annotations

import base64
import curses
import json
import os
import shutil
import shlex
import subprocess
import sys
from urllib import error as urllib_error
from urllib import parse as urllib_parse
from urllib import request as urllib_request
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Sequence, Tuple


@dataclass(frozen=True)
class ChangedFile:
    """Represents one file entry from `git status --short`.

    Attributes:
        index_status: The index status character from porcelain output.
        worktree_status: The worktree status character from porcelain output.
        path: The repository-relative path for the change.
    """

    index_status: str
    worktree_status: str
    path: str

    def short_status(self) -> str:
        """Returns the two-character status code for UI display.

        Returns:
            A two-character status string in the same shape as porcelain output.
        """

        return f"{self.index_status}{self.worktree_status}"


def run_command(
    command: Sequence[str],
    extra_env: Optional[Dict[str, str]] = None,
) -> tuple[int, str]:
    """Executes a command and captures combined output.

    Args:
        command: Full command arguments to execute.
        extra_env: Optional environment variables merged into process env.

    Returns:
        A tuple of `(exit_code, output_text)` where output includes both
        standard output and standard error.
    """

    process_env = os.environ.copy()
    if extra_env:
        process_env.update(extra_env)

    completed = subprocess.run(
        command,
        check=False,
        capture_output=True,
        stdin=subprocess.DEVNULL,
        text=True,
        env=process_env,
    )
    output = (completed.stdout or "") + (completed.stderr or "")
    return completed.returncode, output.rstrip()


def run_git(args: Sequence[str]) -> tuple[int, str]:
    """Executes a Git command and captures combined output.

    Args:
        args: Command arguments to pass after `git`.

    Returns:
        A tuple of `(exit_code, output_text)` where output includes both
        standard output and standard error.
    """

    command = ["git", *args]
    return run_command(
        command,
        extra_env={
            "GIT_TERMINAL_PROMPT": "0",
            "GCM_INTERACTIVE": "Never",
        },
    )


def parse_changed_files(short_status_output: str) -> List[ChangedFile]:
    """Parses `git status --short` output into structured file entries.

    Args:
        short_status_output: Raw output from `git status --short`.

    Returns:
        A list of parsed changed files in displayed order.
    """

    parsed_files: List[ChangedFile] = []
    for line in short_status_output.splitlines():
        if len(line) < 4:
            continue
        index_status = line[0]
        worktree_status = line[1]
        raw_path = line[3:]
        if " -> " in raw_path:
            raw_path = raw_path.split(" -> ", maxsplit=1)[1]
        parsed_files.append(
            ChangedFile(
                index_status=index_status,
                worktree_status=worktree_status,
                path=raw_path,
            )
        )
    return parsed_files


class GitTuiApp:
    """Implements the interactive curses application for Git operations."""

    MENU_ITEMS = [
        ("Stage all changes", "stage_all"),
        ("Stage selected files", "stage_selected"),
        ("Unstage selected files", "unstage_selected"),
        ("Commit staged changes", "commit"),
        ("Push current branch", "push"),
        ("Pull current branch (rebase)", "pull"),
        ("Fetch all remotes", "fetch"),
        ("Show git status", "status"),
        ("Quit", "quit"),
    ]

    def __init__(self, stdscr: "curses._CursesWindow") -> None:
        """Initializes UI state for the curses app.

        Args:
            stdscr: The root curses window provided by `curses.wrapper`.
        """

        self.stdscr = stdscr
        self.status_message = "Ready."

    def run(self) -> None:
        """Runs startup checks and the main menu event loop.

        The startup sequence ensures this directory is a Git repository and,
        when no remotes are configured, offers a one-time onboarding flow to
        connect the local repository to an existing remote.
        """

        self.set_cursor_visibility(0)
        self.stdscr.keypad(True)

        started_without_repository = not self.ensure_inside_git_repo()
        if started_without_repository:
            bootstrapped = self.handle_non_repo_startup()
            if not bootstrapped:
                return
            if not self.ensure_inside_git_repo():
                self.show_output(
                    "Repository setup failed",
                    "Could not initialize a Git repository in this directory.",
                )
                return
        if (
            not started_without_repository
            and not self.handle_repo_without_remote_startup()
        ):
            return

        selected_index = 0

        while True:
            menu_items = self.main_menu_items()
            menu_size = len(menu_items)
            selected_index %= menu_size
            self.draw_main_screen(selected_index, menu_items)
            key = self.stdscr.getch()

            if key in (ord("q"), 27):
                return
            if key in (curses.KEY_UP, ord("k")):
                selected_index = (selected_index - 1) % menu_size
                continue
            if key in (curses.KEY_DOWN, ord("j")):
                selected_index = (selected_index + 1) % menu_size
                continue
            if key in (10, 13, curses.KEY_ENTER):
                action_name = menu_items[selected_index][1]
                if action_name == "quit":
                    return
                self.run_action(action_name)

    def ensure_inside_git_repo(self) -> bool:
        """Checks whether the current working directory is in a Git repository.

        Returns:
            True when inside a Git work tree, otherwise False.
        """

        exit_code, output = run_git(["rev-parse", "--is-inside-work-tree"])
        return exit_code == 0 and output.strip() == "true"

    def handle_non_repo_startup(self) -> bool:
        """Runs an onboarding flow when launched outside a Git repository.

        Returns:
            True when a repository is available after the flow, otherwise False.
        """

        while True:
            action_name = self.select_option_menu(
                "This directory is not a Git repository.",
                "Select setup action",
                [
                    ("Initialize repository only", "init_only"),
                    (
                        "Initialize, create Gitea remote, and sync",
                        "init_create_gitea",
                    ),
                    (
                        "Initialize, connect existing Gitea remote, and sync",
                        "init_connect_gitea",
                    ),
                    ("Quit", "quit"),
                ],
            )
            if action_name is None or action_name == "quit":
                self.status_message = "Canceled repository setup."
                return False
            if action_name == "init_only":
                if self.initialize_repository(show_result=True):
                    return True
                continue
            if action_name == "init_create_gitea":
                if self.initialize_and_publish_gitea_repository():
                    return True
                if self.ensure_inside_git_repo():
                    return True
                continue
            if action_name == "init_connect_gitea":
                if not self.initialize_repository(show_result=False):
                    continue
                if self.connect_existing_gitea_repository(sync=True):
                    return True
                if self.ensure_inside_git_repo():
                    return True
                continue

    def handle_repo_without_remote_startup(self) -> bool:
        """Offers remote setup when a local repository has no remotes.

        Returns:
            True when startup should continue, otherwise False.
        """

        if self.has_any_remote():
            return True

        while True:
            action_name = self.select_option_menu(
                "This repository has no remote configured.",
                "Select startup action",
                [
                    (
                        "Connect existing Gitea remote and sync",
                        "connect_existing_gitea",
                    ),
                    (
                        "Create Gitea remote and sync (API token required)",
                        "create_gitea",
                    ),
                    ("Continue without remote", "continue_without_remote"),
                    ("Quit", "quit"),
                ],
            )
            if action_name is None or action_name == "quit":
                self.status_message = "Canceled startup."
                return False
            if action_name == "continue_without_remote":
                self.status_message = "Continuing without remote."
                return True
            if action_name == "connect_existing_gitea":
                if self.connect_existing_gitea_repository(sync=True):
                    return True
                if self.has_any_remote():
                    return True
                continue
            if action_name == "create_gitea":
                if self.publish_local_repository_to_gitea():
                    return True
                if self.has_any_remote():
                    return True
                continue

    def has_any_remote(self) -> bool:
        """Checks whether at least one Git remote is configured.

        Returns:
            True when at least one remote exists, otherwise False.
        """

        exit_code, output = run_git(["remote"])
        if exit_code != 0:
            return False
        for line in output.splitlines():
            if line.strip():
                return True
        return False

    def connect_existing_remote_repository(self) -> bool:
        """Connects `origin` to an existing remote repository.

        Supports provider-specific input for GitHub and Gitea, and optionally
        pushes the current branch to set upstream tracking.

        Returns:
            True when the repository is connected, otherwise False.
        """

        provider = self.select_option_menu(
            "Connect existing remote",
            "Choose remote provider",
            [
                ("GitHub", "github"),
                ("Gitea", "gitea"),
                ("Cancel", "cancel"),
            ],
        )
        if provider is None or provider == "cancel":
            self.status_message = "Remote connection canceled."
            return False

        remote_url = ""
        if provider == "github":
            remote_url = self.prompt_github_existing_remote_url()
        if provider == "gitea":
            remote_url = self.prompt_gitea_existing_remote_url()
        if not remote_url:
            return False

        if not self.configure_origin_remote(remote_url):
            return False

        push_choice = self.select_option_menu(
            "Push local branch now?",
            "This sets upstream tracking on origin.",
            [
                ("Push now", "push"),
                ("Skip push", "skip"),
                ("Cancel", "cancel"),
            ],
        )
        should_push = push_choice == "push"
        if should_push and not self.push_current_branch_to_origin():
            return False

        remote_name = self.current_branch()
        push_state = "yes" if should_push else "no"
        details = (
            "Connected local repository to an existing remote.\n\n"
            f"Provider: {provider.capitalize()}\n"
            f"Remote URL: {remote_url}\n"
            f"Current branch: {remote_name}\n"
            f"Pushed now: {push_state}"
        )
        self.show_output("Remote connected", details)
        self.status_message = "Connected local repository to origin."
        return True

    def connect_existing_gitea_repository(self, sync: bool = True) -> bool:
        """Connects an existing Gitea repository and optionally synchronizes it.

        Args:
            sync: Whether to reconcile local and remote history immediately.

        Returns:
            True when the remote is connected and synchronization succeeds.
        """

        remote_url = self.prompt_gitea_existing_remote_url()
        if not remote_url:
            return False

        if self.has_local_commit() and not self.is_worktree_clean():
            self.show_output(
                "Local changes need attention",
                "Commit or stash local changes before connecting and syncing.\n\n"
                "No remote was added and no local files were changed.",
            )
            self.status_message = "Commit or stash changes before syncing."
            return False

        if not self.configure_origin_remote(remote_url):
            return False
        if sync and not self.sync_origin_repository():
            return False

        details = (
            "Connected local repository to an existing Gitea remote.\n\n"
            f"Remote URL: {remote_url}\n"
            f"Current branch: {self.current_branch()}\n"
            f"Synchronized: {'yes' if sync else 'no'}"
        )
        self.show_output("Gitea remote connected", details)
        self.status_message = "Connected and synchronized with origin."
        return True

    def sync_origin_repository(self) -> bool:
        """Safely reconciles the local repository with the ``origin`` remote."""

        remote_branch = self.remote_default_branch("origin")
        local_has_commit = self.has_local_commit()

        if not remote_branch:
            heads_exit_code, heads_output = run_git(["ls-remote", "--heads", "origin"])
            if heads_exit_code != 0:
                self.show_command_result(
                    ["git", "ls-remote", "--heads", "origin"],
                    heads_exit_code,
                    heads_output,
                )
                return False
            remote_branches = []
            for line in heads_output.splitlines():
                fields = line.split()
                if len(fields) == 2 and fields[1].startswith("refs/heads/"):
                    remote_branches.append(fields[1].removeprefix("refs/heads/"))
            if remote_branches:
                local_branch = self.current_local_branch()
                if local_branch in remote_branches:
                    remote_branch = local_branch
                elif "main" in remote_branches:
                    remote_branch = "main"
                else:
                    remote_branch = remote_branches[0]

        if not remote_branch:
            if not local_has_commit and not self.create_initial_commit_for_sync():
                return False
            return self.push_head_to_remote_branch(self.current_local_branch())

        fetch_command = ["git", "fetch", "origin", remote_branch]
        fetch_exit_code, fetch_output = run_git(["fetch", "origin", remote_branch])
        if fetch_exit_code != 0:
            self.show_command_result(fetch_command, fetch_exit_code, fetch_output)
            return False

        remote_ref = f"refs/remotes/origin/{remote_branch}"
        if not local_has_commit:
            if self.changed_files():
                if not self.create_initial_commit_for_sync():
                    return False
                local_has_commit = True
            else:
                command = ["git", "switch", "-C", remote_branch, remote_ref]
                exit_code, output = run_git(
                    ["switch", "-C", remote_branch, remote_ref]
                )
                if exit_code != 0:
                    command = ["git", "checkout", "-B", remote_branch, remote_ref]
                    exit_code, output = run_git(
                        ["checkout", "-B", remote_branch, remote_ref]
                    )
                if exit_code != 0:
                    self.show_command_result(command, exit_code, output)
                    return False
                return self.set_branch_upstream(remote_branch, remote_branch)

        local_sha = self.git_revision("HEAD")
        remote_sha = self.git_revision(remote_ref)
        if not local_sha or not remote_sha:
            self.show_output(
                "Synchronization failed",
                "Could not determine local or remote commit state.",
            )
            return False
        if local_sha == remote_sha:
            return self.set_branch_upstream(self.current_local_branch(), remote_branch)

        if self.is_ancestor("HEAD", remote_ref):
            command = ["git", "merge", "--ff-only", remote_ref]
            exit_code, output = run_git(["merge", "--ff-only", remote_ref])
            if exit_code != 0:
                self.show_command_result(command, exit_code, output)
                return False
            return self.set_branch_upstream(self.current_local_branch(), remote_branch)

        if self.is_ancestor(remote_ref, "HEAD"):
            return self.push_head_to_remote_branch(remote_branch)

        return self.resolve_diverged_histories(remote_branch, remote_ref)

    def resolve_diverged_histories(self, remote_branch: str, remote_ref: str) -> bool:
        """Prompts for a safe resolution when local and remote have diverged."""

        choice = self.select_option_menu(
            "Local and remote histories have diverged",
            "Choose which history should win, or merge both",
            [
                ("Merge remote into local, then push", "merge"),
                ("Replace local commits with remote (creates backup branch)", "remote"),
                ("Force-push local commits over remote", "local"),
                ("Cancel", "cancel"),
            ],
        )
        if choice is None or choice == "cancel":
            self.status_message = "Synchronization canceled; origin remains connected."
            return False

        if choice == "merge":
            command = [
                "git",
                "merge",
                "--allow-unrelated-histories",
                remote_ref,
            ]
            exit_code, output = run_git(
                ["merge", "--allow-unrelated-histories", remote_ref]
            )
            if exit_code != 0:
                self.show_command_result(command, exit_code, output)
                self.status_message = "Merge needs manual conflict resolution."
                return False
            return self.push_head_to_remote_branch(remote_branch)

        if choice == "remote":
            confirmed = self.confirm_destructive_sync(
                "Replace local history?",
                "A backup branch will preserve the current local commit.",
            )
            if not confirmed:
                return False
            backup_branch = self.create_local_backup_branch()
            if not backup_branch:
                return False
            command = ["git", "reset", "--hard", remote_ref]
            exit_code, output = run_git(["reset", "--hard", remote_ref])
            if exit_code != 0:
                self.show_command_result(command, exit_code, output)
                return False
            if not self.set_branch_upstream(
                self.current_local_branch(), remote_branch
            ):
                return False
            self.show_output(
                "Local history replaced",
                f"Local files now match {remote_ref}.\n\n"
                f"Previous local commit preserved as: {backup_branch}\n"
                "Untracked files were left untouched.",
            )
            return True

        confirmed = self.confirm_destructive_sync(
            "Overwrite remote history?",
            "This force-pushes the current local commit using --force-with-lease.",
        )
        if not confirmed:
            return False
        return self.push_head_to_remote_branch(remote_branch, force=True)

    def confirm_destructive_sync(self, title: str, subtitle: str) -> bool:
        """Requests explicit confirmation for a history-replacing operation."""

        choice = self.select_option_menu(
            title,
            subtitle,
            [("Cancel", "cancel"), ("Yes, replace history", "confirm")],
        )
        return choice == "confirm"

    def has_local_commit(self) -> bool:
        """Returns whether ``HEAD`` resolves to a local commit."""

        exit_code, _ = run_git(["rev-parse", "--verify", "HEAD"])
        return exit_code == 0

    def is_worktree_clean(self) -> bool:
        """Returns whether tracked, staged, and untracked files are clean."""

        exit_code, output = run_git(["status", "--porcelain"])
        return exit_code == 0 and not output.strip()

    def create_initial_commit_for_sync(self) -> bool:
        """Creates the initial local commit required for pushing a branch."""

        commit_message = self.prompt_input(
            'Initial commit message (blank uses "Initial commit")'
        )
        if commit_message is None:
            commit_message = "Initial commit"
        return self.prepare_initial_commit(commit_message)

    def remote_default_branch(self, remote_name: str) -> str:
        """Finds the default branch advertised by a remote, if it has one."""

        exit_code, output = run_git(
            ["ls-remote", "--symref", remote_name, "HEAD"]
        )
        if exit_code != 0:
            return ""
        for line in output.splitlines():
            if not line.startswith("ref: refs/heads/") or not line.endswith("\tHEAD"):
                continue
            return line.removeprefix("ref: refs/heads/").removesuffix("\tHEAD")
        return ""

    def current_local_branch(self) -> str:
        """Returns a usable current branch name, including unborn branches."""

        exit_code, output = run_git(["symbolic-ref", "--short", "HEAD"])
        if exit_code == 0 and output.strip():
            return output.strip()
        branch_name = self.current_branch()
        return "main" if branch_name == "detached" else branch_name

    def git_revision(self, revision: str) -> str:
        """Resolves a Git revision to its commit identifier."""

        exit_code, output = run_git(["rev-parse", "--verify", revision])
        return output.strip() if exit_code == 0 else ""

    def is_ancestor(self, ancestor: str, descendant: str) -> bool:
        """Returns whether one Git revision is an ancestor of another."""

        exit_code, _ = run_git(["merge-base", "--is-ancestor", ancestor, descendant])
        return exit_code == 0

    def set_branch_upstream(self, local_branch: str, remote_branch: str) -> bool:
        """Sets a local branch to track an origin branch."""

        command = [
            "git",
            "branch",
            f"--set-upstream-to=origin/{remote_branch}",
            local_branch,
        ]
        exit_code, output = run_git(command[1:])
        if exit_code != 0:
            self.show_command_result(command, exit_code, output)
            return False
        return True

    def push_head_to_remote_branch(
        self, remote_branch: str, force: bool = False
    ) -> bool:
        """Pushes local HEAD to a named branch and configures tracking."""

        args = ["push", "-u"]
        if force:
            args.append("--force-with-lease")
        args.extend(["origin", f"HEAD:refs/heads/{remote_branch}"])
        command = ["git", *args]
        exit_code, output = run_git(args)
        if exit_code != 0:
            self.show_command_result(command, exit_code, output)
            return False
        return True

    def create_local_backup_branch(self) -> str:
        """Creates a uniquely named branch preserving the current HEAD."""

        exit_code, short_sha = run_git(["rev-parse", "--short", "HEAD"])
        if exit_code != 0:
            return ""
        short_sha = short_sha.strip()
        base_name = f"gtui-backup-{short_sha}"
        branch_name = base_name
        suffix = 2
        while run_git(["show-ref", "--verify", f"refs/heads/{branch_name}"])[0] == 0:
            branch_name = f"{base_name}-{suffix}"
            suffix += 1
        exit_code, output = run_git(["branch", branch_name, "HEAD"])
        if exit_code != 0:
            self.show_command_result(
                ["git", "branch", branch_name, "HEAD"], exit_code, output
            )
            return ""
        return branch_name

    def prompt_github_existing_remote_url(self) -> str:
        """Prompts for and resolves a GitHub remote URL.

        Returns:
            A normalized remote URL string, or an empty string on cancel or
            invalid input.
        """

        default_repo = os.getenv("GITHUB_REPOSITORY", "").strip()
        prompt = (
            "GitHub repository (owner/name or clone URL, blank uses "
            f'"{default_repo}")'
            if default_repo
            else "GitHub repository (owner/name or clone URL)"
        )
        repo_input = self.prompt_input(prompt)
        if repo_input is None:
            repo_input = default_repo
        remote_url = self.resolve_github_remote_url(repo_input or "")
        if not remote_url:
            self.show_output(
                "Invalid GitHub repository",
                "Provide owner/name (for example acme/project) or a full "
                "clone URL.",
            )
            self.status_message = "Invalid GitHub repository."
            return ""
        return remote_url

    def resolve_github_remote_url(self, repo_input: str) -> str:
        """Resolves GitHub input into a remote URL.

        Args:
            repo_input: Repository locator as owner/name or a clone URL.

        Returns:
            A usable remote URL, or an empty string when input is invalid.
        """

        normalized_input = self.normalize_remote_input(repo_input)
        if not normalized_input:
            return ""
        if self.looks_like_clone_url(normalized_input):
            return normalized_input

        if "/" not in normalized_input:
            return ""
        owner, repo_name = normalized_input.split("/", maxsplit=1)
        owner = owner.strip()
        repo_name = repo_name.strip()
        if not owner or not repo_name:
            return ""
        if repo_name.endswith(".git"):
            repo_name = repo_name[:-4]
        return f"https://github.com/{owner}/{repo_name}.git"

    def prompt_gitea_existing_remote_url(self) -> str:
        """Prompts for and resolves a Gitea remote URL.

        Returns:
            A normalized remote URL string, or an empty string on cancel or
            invalid input.
        """

        default_owner = os.getenv("GITEA_OWNER", "").strip()
        default_repo_name = os.path.basename(os.getcwd()) or "repository"
        default_repo = (
            f"{default_owner}/{default_repo_name}" if default_owner else ""
        )
        default_base_url = (
            os.getenv("GITEA_URL", "").strip()
            or os.getenv("GITEA_BASE_URL", "").strip()
        )

        prompt = (
            "Gitea repository (owner/name or clone URL, blank uses "
            f'"{default_repo}")'
            if default_repo
            else "Gitea repository (owner/name or clone URL)"
        )
        repo_input = self.prompt_input(prompt)
        if repo_input is None:
            repo_input = default_repo
        normalized_repo_input = self.normalize_remote_input(repo_input or "")
        if not normalized_repo_input:
            self.status_message = "Remote connection canceled."
            return ""

        if self.looks_like_clone_url(normalized_repo_input):
            return normalized_repo_input

        base_url_input = self.prompt_input(
            "Gitea base URL (for example https://git.example.com"
            f'{", blank uses " + default_base_url if default_base_url else ""})'
        )
        if base_url_input is None:
            base_url_input = default_base_url
        base_url = self.normalize_base_url(base_url_input or "")
        if not base_url:
            self.show_output(
                "Missing Gitea base URL",
                "A valid Gitea HTTPS base URL is required when using owner/name "
                "repository format.",
            )
            self.status_message = "Missing Gitea base URL."
            return ""

        transport = self.select_gitea_transport()
        if not transport:
            return ""

        remote_url = self.resolve_gitea_remote_url(
            repo_input=normalized_repo_input,
            base_url=base_url,
            transport=transport,
            ssh_user=os.getenv("GITEA_SSH_USER", "git").strip() or "git",
            ssh_port=os.getenv("GITEA_SSH_PORT", "").strip(),
        )
        if not remote_url:
            self.show_output(
                "Invalid Gitea repository",
                "Provide owner/name (for example acme/project) or a full "
                "clone URL.",
            )
            self.status_message = "Invalid Gitea repository."
            return ""
        return remote_url

    def select_gitea_transport(self) -> str:
        """Prompts for the Git transport used by a Gitea remote."""

        transport = self.select_option_menu(
            "Gitea clone protocol",
            "Use the protocol already configured for this Gitea instance",
            [
                ("SSH (uses your configured SSH key)", "ssh"),
                ("HTTPS (uses a Git credential helper)", "https"),
                ("Cancel", "cancel"),
            ],
        )
        if transport is None or transport == "cancel":
            self.status_message = "Remote connection canceled."
            return ""
        return transport

    def resolve_gitea_remote_url(
        self,
        repo_input: str,
        base_url: str,
        transport: str = "https",
        ssh_user: str = "git",
        ssh_port: str = "",
    ) -> str:
        """Builds a Gitea clone URL from owner/name input.

        Args:
            repo_input: Repository locator in owner/name format.
            base_url: Base URL of the Gitea instance.
            transport: Clone protocol, either ``ssh`` or ``https``.
            ssh_user: SSH username used for SSH clone URLs.
            ssh_port: Optional SSH port used for SSH clone URLs.

        Returns:
            The computed clone URL, or an empty string when input is
            invalid.
        """

        normalized_input = self.normalize_remote_input(repo_input)
        if not normalized_input or "/" not in normalized_input:
            return ""
        owner, repo_name = normalized_input.split("/", maxsplit=1)
        owner = owner.strip()
        repo_name = repo_name.strip()
        if not owner or not repo_name:
            return ""
        if repo_name.endswith(".git"):
            repo_name = repo_name[:-4]
        if not repo_name:
            return ""

        if transport == "https":
            return f"{base_url}/{owner}/{repo_name}.git"
        if transport != "ssh":
            return ""

        parsed_base_url = urllib_parse.urlparse(base_url)
        ssh_host = parsed_base_url.hostname or ""
        if not ssh_host or not ssh_user or any(
            character in ssh_user for character in "@/: \t\r\n"
        ):
            return ""
        if ssh_port:
            try:
                port_number = int(ssh_port)
            except ValueError:
                return ""
            if port_number < 1 or port_number > 65535:
                return ""
            uri_host = f"[{ssh_host}]" if ":" in ssh_host else ssh_host
            return (
                f"ssh://{ssh_user}@{uri_host}:{port_number}/"
                f"{owner}/{repo_name}.git"
            )
        if ":" in ssh_host:
            return f"ssh://{ssh_user}@[{ssh_host}]/{owner}/{repo_name}.git"
        return f"{ssh_user}@{ssh_host}:{owner}/{repo_name}.git"

    def normalize_remote_input(self, raw_value: str) -> str:
        """Normalizes user-provided repository input.

        Args:
            raw_value: Raw repository or URL value from user input.

        Returns:
            A trimmed value without trailing slash characters.
        """

        return raw_value.strip().rstrip("/")

    def looks_like_clone_url(self, value: str) -> bool:
        """Checks whether a value appears to be a complete clone URL.

        Args:
            value: Candidate repository value to classify.

        Returns:
            True when value looks like URL or SSH clone syntax.
        """

        if value.startswith("git@") or value.startswith("ssh://"):
            return True
        parsed = urllib_parse.urlparse(value)
        return bool(parsed.scheme and parsed.netloc)

    def select_option_menu(
        self,
        title: str,
        subtitle: str,
        options: Sequence[tuple[str, str]],
    ) -> Optional[str]:
        """Displays a single-select menu and returns selected action.

        Args:
            title: Heading displayed at top of the selection screen.
            subtitle: Secondary helper text shown under the heading.
            options: Display labels and action names for each option.

        Returns:
            The selected action name, or None when canceled.
        """

        selected_index = 0
        while True:
            self.stdscr.erase()
            height, _ = self.stdscr.getmaxyx()

            self.safe_addstr(0, 0, title, curses.A_BOLD)
            self.safe_addstr(1, 0, subtitle)

            for index, (label, _) in enumerate(options):
                prefix = ">" if index == selected_index else " "
                row_text = f"{prefix} {label}"
                row_attr = (
                    curses.A_REVERSE if index == selected_index else curses.A_NORMAL
                )
                self.safe_addstr(3 + index, 0, row_text, row_attr)

            self.safe_addstr(
                height - 1,
                0,
                "Keys: Up/Down or j/k, Enter select, q or Esc cancel",
            )
            self.stdscr.refresh()

            key = self.stdscr.getch()
            if key in (ord("q"), 27):
                return None
            if key in (curses.KEY_UP, ord("k")):
                selected_index = (selected_index - 1) % len(options)
                continue
            if key in (curses.KEY_DOWN, ord("j")):
                selected_index = (selected_index + 1) % len(options)
                continue
            if key in (10, 13, curses.KEY_ENTER):
                return options[selected_index][1]

    def initialize_repository(self, show_result: bool) -> bool:
        """Initializes a Git repository in the current working directory.

        Args:
            show_result: Whether to present initialization output in UI.

        Returns:
            True when initialization succeeds, otherwise False.
        """

        init_with_branch_command = ["git", "init", "-b", "main"]
        exit_code, output = run_git(["init", "-b", "main"])
        if exit_code == 0:
            if show_result:
                self.show_command_result(init_with_branch_command, exit_code, output)
            self.status_message = "Repository initialized."
            return True

        fallback_command = ["git", "init"]
        fallback_exit_code, fallback_output = run_git(["init"])
        if fallback_exit_code != 0:
            if show_result:
                details = (
                    f"Attempt 1\n$ {shlex.join(init_with_branch_command)}\n\n"
                    f"{output or '(no output)'}\n\n"
                    f"Attempt 2\n$ {shlex.join(fallback_command)}\n\n"
                    f"{fallback_output or '(no output)'}"
                )
                self.show_output("Repository initialization failed", details)
            self.status_message = "Repository initialization failed."
            return False

        run_git(["symbolic-ref", "HEAD", "refs/heads/main"])
        if show_result:
            self.show_command_result(
                fallback_command,
                fallback_exit_code,
                fallback_output,
            )
        self.status_message = "Repository initialized."
        return True

    def initialize_and_publish_repository(self) -> bool:
        """Initializes a repository and publishes it to a selected provider.

        Returns:
            True when setup and publication succeed, otherwise False.
        """

        provider = self.select_option_menu(
            "Publish target",
            "Choose where the new remote repository will be created",
            [
                ("GitHub (gh CLI)", "github"),
                ("Gitea (HTTPS API)", "gitea"),
                ("Cancel", "cancel"),
            ],
        )
        if provider is None or provider == "cancel":
            self.status_message = "Publishing canceled."
            return False

        if not self.initialize_repository(show_result=False):
            return False

        commit_message = self.prompt_input(
            'Initial commit message (blank uses "Initial commit")'
        )
        if commit_message is None:
            commit_message = "Initial commit"

        if not self.prepare_initial_commit(commit_message):
            return False

        if provider == "github":
            return self.publish_github_repository()
        if provider == "gitea":
            return self.publish_gitea_repository()

        self.status_message = "Publishing canceled."
        return False

    def initialize_and_publish_gitea_repository(self) -> bool:
        """Initializes locally, creates a Gitea repository, and pushes to it."""

        if not self.initialize_repository(show_result=False):
            return False
        if not self.create_initial_commit_for_sync():
            return False
        return self.publish_gitea_repository()

    def publish_local_repository_to_gitea(self) -> bool:
        """Creates a Gitea remote for the current local repository and syncs it."""

        if not self.has_local_commit():
            if not self.create_initial_commit_for_sync():
                return False
        elif not self.is_worktree_clean():
            self.show_output(
                "Local changes need attention",
                "Commit or stash local changes before creating and syncing a "
                "Gitea remote.\n\nNo remote was created.",
            )
            self.status_message = "Commit or stash changes before syncing."
            return False
        return self.publish_gitea_repository()

    def prepare_initial_commit(self, commit_message: str) -> bool:
        """Stages all changes and creates the initial local commit.

        Args:
            commit_message: Commit message to use for the initial commit.

        Returns:
            True when commit preparation succeeds, otherwise False.
        """

        add_command = ["git", "add", "-A"]
        add_exit_code, add_output = run_git(["add", "-A"])
        if add_exit_code != 0:
            self.show_command_result(add_command, add_exit_code, add_output)
            return False

        commit_command = ["git", "commit", "-m", commit_message]
        commit_exit_code, commit_output = run_git(["commit", "-m", commit_message])
        if commit_exit_code != 0 and "nothing to commit" in commit_output.lower():
            commit_command = ["git", "commit", "--allow-empty", "-m", commit_message]
            commit_exit_code, commit_output = run_git(
                ["commit", "--allow-empty", "-m", commit_message]
            )
        if commit_exit_code != 0:
            self.show_command_result(commit_command, commit_exit_code, commit_output)
            return False
        return True

    def select_repository_visibility(self) -> Optional[bool]:
        """Prompts for repository visibility.

        Returns:
            True for private visibility, False for public, or None when canceled.
        """

        visibility = self.select_option_menu(
            "Repository visibility",
            "Choose visibility for the new remote repository",
            [("Private", "private"), ("Public", "public"), ("Cancel", "cancel")],
        )
        if visibility is None or visibility == "cancel":
            self.status_message = "Publishing canceled."
            return None
        return visibility == "private"

    def publish_github_repository(self) -> bool:
        """Creates and pushes a repository on GitHub using `gh`.

        Returns:
            True when repository creation and push succeed, otherwise False.
        """

        if not self.is_command_available("gh"):
            self.show_output(
                "GitHub CLI not found",
                "Publishing to GitHub requires the `gh` command.\n"
                "Install `gh`, authenticate it, then retry.",
            )
            self.status_message = "GitHub CLI not found."
            return False

        default_repo_name = os.path.basename(os.getcwd()) or "repository"
        repo_name = self.prompt_input(
            "GitHub repository name (owner/name or name, blank uses "
            f'"{default_repo_name}")'
        )
        if repo_name is None:
            repo_name = default_repo_name

        is_private = self.select_repository_visibility()
        if is_private is None:
            return False

        visibility_flag = "--private" if is_private else "--public"
        create_command = [
            "gh",
            "repo",
            "create",
            repo_name,
            visibility_flag,
            "--source=.",
            "--remote=origin",
            "--push",
        ]
        create_exit_code, create_output = run_command(create_command)
        if create_exit_code != 0:
            self.show_command_result(create_command, create_exit_code, create_output)
            return False

        visibility_label = "private" if is_private else "public"
        summary = (
            "Initialized this directory as a Git repository and published it.\n\n"
            f"Provider: GitHub\n"
            f"Repository: {repo_name}\n"
            f"Visibility: {visibility_label}\n\n"
            f"$ {shlex.join(create_command)}\n\n"
            f"{create_output or '(no output)'}"
        )
        self.show_output("Repository published", summary)
        self.status_message = f"Published repository: {repo_name}"
        return True

    def publish_gitea_repository(self) -> bool:
        """Creates a Gitea repository through its API and pushes with Git.

        Returns:
            True when repository creation and push succeed, otherwise False.
        """

        default_repo_name = os.path.basename(os.getcwd()) or "repository"
        default_owner = os.getenv("GITEA_OWNER", "").strip()
        default_base_url = (
            os.getenv("GITEA_URL", "").strip()
            or os.getenv("GITEA_BASE_URL", "").strip()
        )

        repo_input = self.prompt_input(
            "Gitea repository (owner/name or name, blank uses "
            f'"{default_repo_name}")'
        )
        if repo_input is None:
            repo_input = default_repo_name
        repo_input = repo_input.strip()

        owner = default_owner
        repo_name = repo_input
        if "/" in repo_input:
            owner_part, repo_part = repo_input.split("/", maxsplit=1)
            owner = owner_part.strip()
            repo_name = repo_part.strip()

        if not repo_name:
            self.status_message = "Invalid repository name."
            return False

        base_url_input = self.prompt_input(
            "Gitea base URL (for example https://git.example.com"
            f'{", blank uses " + default_base_url if default_base_url else ""})'
        )
        if base_url_input is None:
            base_url_input = default_base_url
        base_url = self.normalize_base_url(base_url_input or "")
        if not base_url:
            self.show_output(
                "Missing Gitea base URL",
                "A valid Gitea HTTPS base URL is required to create a repository.",
            )
            self.status_message = "Missing Gitea base URL."
            return False

        transport = self.select_gitea_transport()
        if not transport:
            return False

        is_private = self.select_repository_visibility()
        if is_private is None:
            return False

        token = (
            os.getenv("GITEA_TOKEN", "").strip()
            or os.getenv("GITEA_ACCESS_TOKEN", "").strip()
        )
        if not token:
            token = self.prompt_secret("Gitea API token (required to create remote)")
            if token is None:
                self.status_message = "Missing Gitea API token."
                return False

        created, response_data, response_body = self.create_gitea_repository(
            base_url=base_url,
            token=token,
            owner=owner,
            repo_name=repo_name,
            is_private=is_private,
        )
        if not created:
            self.show_output("Gitea repository creation failed", response_body)
            self.status_message = "Gitea repository creation failed."
            return False

        clone_url = str(response_data.get("clone_url") or "").strip()
        ssh_url = str(response_data.get("ssh_url") or "").strip()
        full_name = str(response_data.get("full_name") or "").strip()
        if not clone_url:
            if full_name:
                clone_url = f"{base_url}/{full_name}.git"
            elif owner:
                clone_url = f"{base_url}/{owner}/{repo_name}.git"
        if not clone_url:
            self.show_output(
                "Missing clone URL",
                "Repository was created but no HTTPS clone URL was returned.",
            )
            self.status_message = "Repository created without clone URL."
            return False

        remote_url = clone_url
        if transport == "ssh":
            remote_url = ssh_url or self.resolve_gitea_remote_url(
                repo_input=full_name or f"{owner}/{repo_name}",
                base_url=base_url,
                transport="ssh",
                ssh_user=os.getenv("GITEA_SSH_USER", "git").strip() or "git",
                ssh_port=os.getenv("GITEA_SSH_PORT", "").strip(),
            )
            if not remote_url:
                self.show_output(
                    "Missing SSH clone URL",
                    "Repository was created, but an SSH clone URL could not be "
                    "resolved. Configure origin manually using the clone URL "
                    "shown by Gitea.",
                )
                self.status_message = "Repository created without SSH clone URL."
                return False

        if not self.configure_origin_remote(remote_url):
            return False
        if transport == "ssh":
            if not self.push_head_to_remote_branch(self.current_local_branch()):
                return False
        else:
            push_username = self.resolve_gitea_push_username(
                base_url=base_url,
                token=token,
                response_data=response_data,
            )
            if not push_username:
                self.status_message = "Missing Gitea username for push."
                return False
            if not self.push_current_branch_to_origin_with_token(
                username=push_username,
                token=token,
            ):
                return False

        visibility_label = "private" if is_private else "public"
        summary_lines = [
            "Initialized this directory as a Git repository and published it.",
            "",
            "Provider: Gitea",
            f"Repository: {full_name or repo_name}",
            f"Visibility: {visibility_label}",
            f"Remote URL: {remote_url}",
        ]
        self.show_output("Repository published", "\n".join(summary_lines))
        self.status_message = f"Published repository: {full_name or repo_name}"
        return True

    def create_gitea_repository(
        self,
        base_url: str,
        token: str,
        owner: str,
        repo_name: str,
        is_private: bool,
    ) -> Tuple[bool, Dict[str, Any], str]:
        """Creates a Gitea repository via HTTP API.

        Args:
            base_url: Base HTTPS URL of the Gitea instance.
            token: Personal access token used for API authentication.
            owner: Optional owner or organization name.
            repo_name: New repository name.
            is_private: Whether repository visibility should be private.

        Returns:
            A tuple of `(success, response_data, message)` for UI handling.
        """

        payload: Dict[str, Any] = {"name": repo_name, "private": is_private}
        if owner:
            endpoint = f"{base_url}/api/v1/orgs/{urllib_parse.quote(owner)}/repos"
        else:
            endpoint = f"{base_url}/api/v1/user/repos"

        status_code, body_text, body_data = self.gitea_post_json(
            url=endpoint,
            token=token,
            payload=payload,
        )
        if status_code in (200, 201):
            return True, body_data, body_text or "(no output)"

        message = str(body_data.get("message") or body_text or "request failed")
        if owner and status_code in (403, 404):
            message = (
                f"{message}\n\n"
                "Hint: For personal repositories, provide just the repository name."
            )
        failure_text = (
            f"HTTP status: {status_code or 'request error'}\n"
            f"Endpoint: {endpoint}\n\n"
            f"{message}"
        )
        return False, body_data, failure_text

    def gitea_post_json(
        self,
        url: str,
        token: str,
        payload: Dict[str, Any],
    ) -> Tuple[int, str, Dict[str, Any]]:
        """Sends a JSON POST request to Gitea and parses JSON response.

        Args:
            url: Absolute API endpoint URL.
            token: Personal access token for authentication.
            payload: JSON payload for request body.

        Returns:
            A tuple of `(status_code, response_text, response_data)`.
        """

        body_bytes = json.dumps(payload).encode("utf-8")
        request = urllib_request.Request(
            url=url,
            data=body_bytes,
            method="POST",
            headers={
                "Accept": "application/json",
                "Authorization": f"token {token}",
                "Content-Type": "application/json",
            },
        )
        try:
            with urllib_request.urlopen(request, timeout=30) as response:
                response_text = response.read().decode("utf-8", errors="replace")
                response_data = self.parse_json_object(response_text)
                return response.getcode(), response_text, response_data
        except urllib_error.HTTPError as error:
            response_text = error.read().decode("utf-8", errors="replace")
            response_data = self.parse_json_object(response_text)
            return error.code, response_text, response_data
        except urllib_error.URLError as error:
            return 0, str(error.reason), {}

    def gitea_get_json(self, url: str, token: str) -> Tuple[int, str, Dict[str, Any]]:
        """Sends a JSON GET request to Gitea and parses JSON response.

        Args:
            url: Absolute API endpoint URL.
            token: Personal access token for authentication.

        Returns:
            A tuple of `(status_code, response_text, response_data)`.
        """

        request = urllib_request.Request(
            url=url,
            method="GET",
            headers={
                "Accept": "application/json",
                "Authorization": f"token {token}",
            },
        )
        try:
            with urllib_request.urlopen(request, timeout=30) as response:
                response_text = response.read().decode("utf-8", errors="replace")
                response_data = self.parse_json_object(response_text)
                return response.getcode(), response_text, response_data
        except urllib_error.HTTPError as error:
            response_text = error.read().decode("utf-8", errors="replace")
            response_data = self.parse_json_object(response_text)
            return error.code, response_text, response_data
        except urllib_error.URLError as error:
            return 0, str(error.reason), {}

    def resolve_gitea_push_username(
        self,
        base_url: str,
        token: str,
        response_data: Dict[str, Any],
    ) -> Optional[str]:
        """Resolves a username for authenticated HTTPS push operations.

        Args:
            base_url: Base HTTPS URL of the Gitea instance.
            token: Personal access token used for API authentication.
            response_data: Repository creation response data.

        Returns:
            A username string, or None when unavailable.
        """

        status_code, _, user_data = self.gitea_get_json(
            url=f"{base_url}/api/v1/user",
            token=token,
        )
        if status_code in (200, 201):
            api_login = str(user_data.get("login") or "").strip()
            if api_login:
                return api_login

        owner_data = response_data.get("owner")
        if isinstance(owner_data, dict):
            owner_login = str(
                owner_data.get("login") or owner_data.get("username") or ""
            ).strip()
            if owner_login:
                return owner_login

        prompt_value = self.prompt_input("Gitea username for HTTPS push")
        if prompt_value is None:
            return None
        username = prompt_value.strip()
        if not username:
            return None
        return username

    def parse_json_object(self, text: str) -> Dict[str, Any]:
        """Parses a JSON object string into a dictionary.

        Args:
            text: Raw JSON text from an API response.

        Returns:
            Parsed dictionary content, or an empty dictionary.
        """

        if not text:
            return {}
        try:
            value = json.loads(text)
        except json.JSONDecodeError:
            return {}
        if isinstance(value, dict):
            return value
        return {}

    def configure_origin_remote(self, remote_url: str) -> bool:
        """Adds or updates `origin` to the provided remote URL.

        Args:
            remote_url: Remote URL assigned to `origin`.

        Returns:
            True when remote configuration succeeds, otherwise False.
        """

        check_exit_code, _ = run_git(["remote", "get-url", "origin"])
        if check_exit_code == 0:
            command = ["git", "remote", "set-url", "origin", remote_url]
            exit_code, output = run_git(["remote", "set-url", "origin", remote_url])
        else:
            command = ["git", "remote", "add", "origin", remote_url]
            exit_code, output = run_git(["remote", "add", "origin", remote_url])
        if exit_code != 0:
            self.show_command_result(command, exit_code, output)
            return False
        return True

    def push_current_branch_to_origin(self) -> bool:
        """Pushes the current branch to `origin` and sets upstream tracking.

        Returns:
            True when the push succeeds, otherwise False.
        """

        branch_name = self.current_branch()
        if branch_name == "detached":
            branch_name = "main"
        command = ["git", "push", "-u", "origin", branch_name]
        exit_code, output = run_git(["push", "-u", "origin", branch_name])
        if exit_code != 0:
            self.show_command_result(command, exit_code, output)
            return False
        return True

    def push_current_branch_to_origin_with_token(
        self,
        username: str,
        token: str,
    ) -> bool:
        """Pushes current branch to `origin` using HTTPS basic authentication.

        Args:
            username: Username used for HTTP basic authentication.
            token: Token used as the HTTP basic password value.

        Returns:
            True when the push succeeds, otherwise False.
        """

        branch_name = self.current_branch()
        if branch_name == "detached":
            branch_name = "main"

        auth_value = base64.b64encode(
            f"{username}:{token}".encode("utf-8")
        ).decode("ascii")
        command = [
            "git",
            "-c",
            f"http.extraHeader=Authorization: Basic {auth_value}",
            "push",
            "-u",
            "origin",
            branch_name,
        ]
        exit_code, output = run_command(
            command,
            extra_env={
                "GIT_TERMINAL_PROMPT": "0",
                "GCM_INTERACTIVE": "Never",
            },
        )
        if exit_code != 0:
            details = (
                "Push to origin failed.\n\n"
                f"{output or '(no output)'}"
            )
            self.show_output("Push failed", details)
            self.status_message = "Push to origin failed."
            return False
        return True

    def normalize_base_url(self, raw_base_url: str) -> str:
        """Normalizes a host value into a usable HTTPS base URL.

        Args:
            raw_base_url: User-entered base URL or host.

        Returns:
            A normalized URL string, or an empty string when invalid.
        """

        base_url = raw_base_url.strip()
        if not base_url:
            return ""
        if not base_url.startswith(("http://", "https://")):
            base_url = f"https://{base_url}"
        parsed = urllib_parse.urlparse(base_url)
        if not parsed.scheme or not parsed.netloc:
            return ""
        normalized_path = parsed.path.rstrip("/")
        return f"{parsed.scheme}://{parsed.netloc}{normalized_path}".rstrip("/")

    def is_command_available(self, command_name: str) -> bool:
        """Checks whether an executable is available in PATH.

        Args:
            command_name: Command name to look up.

        Returns:
            True when command exists in PATH, otherwise False.
        """

        return shutil.which(command_name) is not None

    def current_branch(self) -> str:
        """Determines the current branch name for header display.

        Returns:
            The current branch name, or `detached` if HEAD is detached.
        """

        exit_code, output = run_git(["branch", "--show-current"])
        if exit_code != 0 or not output.strip():
            return "detached"
        return output.strip()

    def changed_files(self) -> List[ChangedFile]:
        """Reads the working tree status and returns changed files.

        Returns:
            A list of changed files represented in short porcelain format.
        """

        exit_code, output = run_git(
            ["-c", "core.quotePath=false", "status", "--short"]
        )
        if exit_code != 0:
            return []
        return parse_changed_files(output)

    def main_menu_items(self) -> List[tuple[str, str]]:
        """Builds the main menu, including remote-only operations when useful."""

        menu_items = list(self.MENU_ITEMS)
        has_remote = self.has_any_remote()
        if not has_remote:
            menu_items = [
                item
                for item in menu_items
                if item[1] not in ("push", "pull", "fetch")
            ]
        else:
            status_index = next(
                index
                for index, (_, action_name) in enumerate(menu_items)
                if action_name == "status"
            )
            menu_items.insert(status_index, ("Disconnect remote", "disconnect_remote"))
        return menu_items

    def draw_main_screen(
        self,
        selected_index: int,
        menu_items: Optional[Sequence[tuple[str, str]]] = None,
    ) -> None:
        """Renders the main menu with repository summary information.

        Args:
            selected_index: Index of the currently highlighted menu item.
            menu_items: Menu entries to render; defaults to current dynamic menu.
        """

        self.stdscr.erase()
        height, width = self.stdscr.getmaxyx()
        branch_name = self.current_branch()
        changed_count = len(self.changed_files())

        self.safe_addstr(
            0,
            0,
            f"git-tui | branch: {branch_name} | changed files: {changed_count}",
            curses.A_BOLD,
        )

        displayed_items = menu_items or self.main_menu_items()
        for idx, (label, _) in enumerate(displayed_items):
            prefix = ">" if idx == selected_index else " "
            row_text = f"{prefix} {label}"
            attr = curses.A_REVERSE if idx == selected_index else curses.A_NORMAL
            self.safe_addstr(2 + idx, 0, row_text, attr)

        self.safe_addstr(height - 2, 0, self.status_message[: max(width - 1, 1)])
        self.safe_addstr(
            height - 1,
            0,
            "Keys: Up/Down or j/k, Enter run, q or Esc quit",
        )
        self.stdscr.refresh()

    def safe_addstr(
        self, row: int, col: int, text: str, attr: int = curses.A_NORMAL
    ) -> None:
        """Draws text safely inside current terminal bounds.

        Args:
            row: Target row position in the active window.
            col: Target column position in the active window.
            text: The text content to draw.
            attr: Optional curses text attribute.
        """

        height, width = self.stdscr.getmaxyx()
        if row < 0 or row >= height or col >= width:
            return
        max_chars = max(width - col - 1, 0)
        if max_chars == 0:
            return
        clipped_text = text[:max_chars]
        try:
            self.stdscr.addstr(row, col, clipped_text, attr)
        except curses.error:
            return

    def set_cursor_visibility(self, visibility: int) -> None:
        """Sets cursor visibility without failing on unsupported terminals.

        Args:
            visibility: Curses cursor mode where 0 hides and 1 shows.
        """

        try:
            curses.curs_set(visibility)
        except curses.error:
            return

    def run_action(self, action_name: str) -> None:
        """Dispatches a selected menu action.

        Args:
            action_name: Identifier for the action selected from the menu.
        """

        if action_name == "stage_all":
            self.action_stage_all()
            return
        if action_name == "stage_selected":
            self.action_stage_selected()
            return
        if action_name == "unstage_selected":
            self.action_unstage_selected()
            return
        if action_name == "commit":
            self.action_commit()
            return
        if action_name == "push":
            self.action_push()
            return
        if action_name == "pull":
            self.action_pull()
            return
        if action_name == "fetch":
            self.action_fetch()
            return
        if action_name == "disconnect_remote":
            self.action_disconnect_remote()
            return
        if action_name == "status":
            self.action_status()
            return

    def action_stage_all(self) -> None:
        """Stages all tracked and untracked changes."""

        exit_code, output = run_git(["add", "-A"])
        self.show_command_result(["git", "add", "-A"], exit_code, output)

    def action_stage_selected(self) -> None:
        """Prompts for changed files and stages selected paths."""

        files = self.changed_files()
        if not files:
            self.status_message = "No changed files to stage."
            return

        selected_paths = self.select_files(files, "Select files to stage")
        if not selected_paths:
            self.status_message = "Stage selected canceled."
            return

        exit_code, output = run_git(["add", "--", *selected_paths])
        self.show_command_result(
            ["git", "add", "--", *selected_paths], exit_code, output
        )

    def action_unstage_selected(self) -> None:
        """Prompts for staged files and unstages selected paths."""

        staged_files = [
            file_entry
            for file_entry in self.changed_files()
            if file_entry.index_status not in (" ", "?")
        ]
        if not staged_files:
            self.status_message = "No staged files to unstage."
            return

        selected_paths = self.select_files(staged_files, "Select files to unstage")
        if not selected_paths:
            self.status_message = "Unstage selected canceled."
            return

        exit_code, output = run_git(["restore", "--staged", "--", *selected_paths])
        self.show_command_result(
            ["git", "restore", "--staged", "--", *selected_paths],
            exit_code,
            output,
        )

    def action_commit(self) -> None:
        """Prompts for a commit message and runs `git commit`."""

        if not self.has_staged_changes():
            self.status_message = "No staged changes to commit."
            return

        commit_message = self.prompt_input("Commit message")
        if commit_message is None:
            self.status_message = "Commit canceled."
            return

        exit_code, output = run_git(["commit", "-m", commit_message])
        self.show_command_result(
            ["git", "commit", "-m", commit_message],
            exit_code,
            output,
        )

    def action_push(self) -> None:
        """Pushes current branch changes to default remote."""

        exit_code, output = run_git(["push"])
        self.show_command_result(["git", "push"], exit_code, output)

    def action_pull(self) -> None:
        """Pulls remote changes using rebase mode."""

        exit_code, output = run_git(["pull", "--rebase"])
        self.show_command_result(["git", "pull", "--rebase"], exit_code, output)

    def action_fetch(self) -> None:
        """Fetches updates from all remotes and prunes deleted refs."""

        exit_code, output = run_git(["fetch", "--all", "--prune"])
        self.show_command_result(
            ["git", "fetch", "--all", "--prune"], exit_code, output
        )

    def action_disconnect_remote(self) -> None:
        """Selects and removes a configured Git remote after confirmation."""

        exit_code, output = run_git(["remote"])
        remote_names = [line.strip() for line in output.splitlines() if line.strip()]
        if exit_code != 0 or not remote_names:
            self.status_message = "No remote is configured."
            return

        if len(remote_names) == 1:
            remote_name = remote_names[0]
        else:
            options = [(name, name) for name in remote_names]
            options.append(("Cancel", "cancel"))
            selected = self.select_option_menu(
                "Disconnect remote",
                "Choose which remote configuration to remove",
                options,
            )
            if selected is None or selected == "cancel":
                self.status_message = "Disconnect canceled."
                return
            remote_name = selected

        remote_url_exit, remote_url = run_git(["remote", "get-url", remote_name])
        if remote_url_exit != 0:
            remote_url = "(URL unavailable)"
        confirmation = self.select_option_menu(
            f'Disconnect remote "{remote_name}"?',
            remote_url,
            [("Cancel", "cancel"), ("Remove remote configuration", "remove")],
        )
        if confirmation != "remove":
            self.status_message = "Disconnect canceled."
            return

        command = ["git", "remote", "remove", remote_name]
        remove_exit, remove_output = run_git(["remote", "remove", remote_name])
        self.show_command_result(command, remove_exit, remove_output)

    def action_status(self) -> None:
        """Shows detailed repository status output."""

        exit_code, output = run_git(["-c", "core.quotePath=false", "status"])
        if exit_code != 0:
            self.show_command_result(
                ["git", "-c", "core.quotePath=false", "status"],
                exit_code,
                output,
            )
            return
        self.show_output("git status", output or "(no output)")
        self.status_message = "Displayed repository status."

    def has_staged_changes(self) -> bool:
        """Checks whether there are staged changes ready to commit.

        Returns:
            True when `git diff --cached --quiet` reports staged differences.
        """

        exit_code, _ = run_git(["diff", "--cached", "--quiet"])
        if exit_code == 0:
            return False
        if exit_code == 1:
            return True
        return False

    def prompt_input(self, label: str) -> Optional[str]:
        """Prompts for a single line of text input near the screen footer.

        Args:
            label: Prompt label displayed before the input field.

        Returns:
            The entered text, or None when canceled or empty.
        """

        height, width = self.stdscr.getmaxyx()
        prompt = f"{label}: "
        max_input_len = max(width - len(prompt) - 1, 1)

        self.safe_addstr(height - 2, 0, " " * max(width - 1, 1))
        self.safe_addstr(height - 2, 0, prompt)
        self.stdscr.refresh()

        curses.echo()
        self.set_cursor_visibility(1)
        try:
            raw_input = self.stdscr.getstr(height - 2, len(prompt), max_input_len)
        except curses.error:
            raw_input = b""
        finally:
            curses.noecho()
            self.set_cursor_visibility(0)

        value = raw_input.decode("utf-8", errors="ignore").strip()
        if not value:
            return None
        return value

    def prompt_secret(self, label: str) -> Optional[str]:
        """Prompts for a single line of hidden text input.

        Args:
            label: Prompt label displayed before the hidden input field.

        Returns:
            The entered secret value, or None when empty.
        """

        height, width = self.stdscr.getmaxyx()
        prompt = f"{label}: "
        max_input_len = max(width - len(prompt) - 1, 1)

        self.safe_addstr(height - 2, 0, " " * max(width - 1, 1))
        self.safe_addstr(height - 2, 0, prompt)
        self.stdscr.refresh()

        curses.noecho()
        self.set_cursor_visibility(1)
        try:
            raw_input = self.stdscr.getstr(height - 2, len(prompt), max_input_len)
        except curses.error:
            raw_input = b""
        finally:
            curses.noecho()
            self.set_cursor_visibility(0)

        value = raw_input.decode("utf-8", errors="ignore").strip()
        if not value:
            return None
        return value

    def select_files(
        self, files: Sequence[ChangedFile], title: str
    ) -> List[str]:
        """Shows a multiselect list of files and returns chosen paths.

        Args:
            files: Candidate file entries that can be selected.
            title: Header title displayed in the selector.

        Returns:
            A list of selected file paths, or an empty list when canceled.
        """

        selected = [False] * len(files)
        cursor_index = 0
        top_index = 0

        while True:
            self.stdscr.erase()
            height, _ = self.stdscr.getmaxyx()

            self.safe_addstr(0, 0, title, curses.A_BOLD)
            self.safe_addstr(
                1,
                0,
                "Space toggle, a toggle all, Enter confirm, q cancel",
            )

            list_height = max(height - 4, 1)
            if cursor_index < top_index:
                top_index = cursor_index
            if cursor_index >= top_index + list_height:
                top_index = cursor_index - list_height + 1

            for row_offset in range(list_height):
                file_index = top_index + row_offset
                if file_index >= len(files):
                    break
                marker = "x" if selected[file_index] else " "
                file_entry = files[file_index]
                row_text = (
                    f"[{marker}] {file_entry.short_status()} {file_entry.path}"
                )
                row_attr = (
                    curses.A_REVERSE
                    if file_index == cursor_index
                    else curses.A_NORMAL
                )
                self.safe_addstr(2 + row_offset, 0, row_text, row_attr)

            self.stdscr.refresh()
            key = self.stdscr.getch()

            if key in (ord("q"), 27):
                return []
            if key in (10, 13, curses.KEY_ENTER):
                return [
                    file_entry.path
                    for idx, file_entry in enumerate(files)
                    if selected[idx]
                ]
            if key in (curses.KEY_UP, ord("k")):
                cursor_index = (cursor_index - 1) % len(files)
                continue
            if key in (curses.KEY_DOWN, ord("j")):
                cursor_index = (cursor_index + 1) % len(files)
                continue
            if key == ord(" "):
                selected[cursor_index] = not selected[cursor_index]
                continue
            if key == ord("a"):
                new_value = not all(selected)
                selected = [new_value] * len(files)
                continue

    def show_command_result(
        self, command: Sequence[str], exit_code: int, output: str
    ) -> None:
        """Displays command output and updates status message.

        Args:
            command: Full command arguments for display.
            exit_code: Process exit code from command execution.
            output: Captured process output.
        """

        command_text = shlex.join(command)
        result_title = "Command succeeded" if exit_code == 0 else "Command failed"
        result_body = f"$ {command_text}\n\n{output or '(no output)'}"
        self.show_output(result_title, result_body)

        if exit_code == 0:
            self.status_message = f"Success: {command_text}"
        else:
            self.status_message = f"Failed ({exit_code}): {command_text}"

    def show_output(self, title: str, text: str) -> None:
        """Shows a scrollable read-only text view.

        Args:
            title: Header displayed at the top of the output view.
            text: Content rendered line by line.
        """

        lines = text.splitlines() if text else ["(no output)"]
        offset = 0

        while True:
            self.stdscr.erase()
            height, _ = self.stdscr.getmaxyx()
            view_height = max(height - 3, 1)

            self.safe_addstr(0, 0, title, curses.A_BOLD)
            for row_offset in range(view_height):
                line_index = offset + row_offset
                if line_index >= len(lines):
                    break
                self.safe_addstr(1 + row_offset, 0, lines[line_index])

            self.safe_addstr(
                height - 1,
                0,
                "Keys: Up/Down scroll, PgUp/PgDn page, q or Enter close",
            )
            self.stdscr.refresh()

            key = self.stdscr.getch()
            if key in (ord("q"), 27, 10, 13, curses.KEY_ENTER):
                return
            if key == curses.KEY_UP:
                offset = max(0, offset - 1)
                continue
            if key == curses.KEY_DOWN:
                max_offset = max(len(lines) - view_height, 0)
                offset = min(max_offset, offset + 1)
                continue
            if key == curses.KEY_PPAGE:
                offset = max(0, offset - view_height)
                continue
            if key == curses.KEY_NPAGE:
                max_offset = max(len(lines) - view_height, 0)
                offset = min(max_offset, offset + view_height)
                continue


def run_app(stdscr: "curses._CursesWindow") -> None:
    """Creates and runs the curses Git TUI application.

    Args:
        stdscr: Root curses window provided by `curses.wrapper`.
    """

    app = GitTuiApp(stdscr)
    app.run()


def main() -> int:
    """Runs the program entry point and maps terminal failures to exit codes.

    Returns:
        Process exit code where 0 indicates success.
    """

    try:
        curses.wrapper(run_app)
    except KeyboardInterrupt:
        return 130
    except curses.error as error:
        print(f"Terminal error: {error}", file=sys.stderr)
        return 1
    return 0


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