#                 __
#    ____ _____  |  | _____
#   /    \\__  \ |  | \__  \
#  |   |  \/ __ \|  |__/ __ \_
#  |___|  (____  /____(____  /
#       \/     \/          \/
#
# Copyright (C) 2021, 2022 Blake Lee
#
# This file is part of nala
#
# nala is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# nala is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with nala.  If not, see <https://www.gnu.org/licenses/>.
"""Nala dpkg module."""
from __future__ import annotations

import contextlib
import errno
import fcntl
import os
import pty
import re
import signal
import struct
import sys
import termios
from time import sleep, time
from traceback import format_exception
from types import FrameType
from typing import Match, TextIO

import apt_pkg
from apt.progress import base, text
from pexpect.fdpexpect import fdspawn
from pexpect.utils import poll_ignore_interrupts
from ptyprocess.ptyprocess import _setwinsize

from nala import _, color
from nala.constants import (
	DPKG_ERRORS,
	DPKG_STATUS,
	ERROR_PREFIX,
	HANDLER,
	NOTICES,
	SPAM,
	WARNING_PREFIX,
)
from nala.options import arguments
from nala.rich import (
	ELLIPSIS,
	OVERFLOW,
	Group,
	Live,
	Panel,
	RenderableType,
	Table,
	TaskID,
	Thread,
	ascii_replace,
	dpkg_progress,
	from_ansi,
	spinner,
	to_str,
)
from nala.utils import dprint, eprint, term, unit_str

VERSION_PATTERN = re.compile(r"\(.*?\)")
PARENTHESIS_PATTERN = re.compile(r"[()]")

notice: list[str] = []
pkgnames: set[str] = set()
unpacked: set[str] = set()
dpkg_error: list[str] = []
update_error: list[str] = []

REMOVING = "Removing"
UNPACKING = "Unpacking"
SETTING_UP = "Setting up"
PROCESSING = "Processing"
GET = "GET"

UPDATED = _("Updated:")
DOWNLOADED = _("Downloaded:")
IGNORED = _("Ignored:")
NO_CHANGE = _("No Change:")

# NOTE: Spacing of following status messages
# NOTE: is to allow the urls to be properly aligned
# NOTE: Especially if your status would come after the package
# NOTE: You do not have to follow this scheme
# NOTE: but do note that the headers will be colored regardless
# NOTE: No Change: http://deb.volian.org/volian scar InRelease
# NOTE: Ignored:   http://deb.volian.org/volian scar InRelease
# NOTE: Updated:   http://deb.volian.org/volian scar InRelease
NO_CHANGE_MSG = _("{no_change} {info}")
NO_CHANGE_SIZE_MSG = _("{no_change} {info} [{size}]")
IGNORED_MSG = _("{ignored}   {info}")
UPDATE_MSG = _("{updated}   {info}")
UPDATE_SIZE_MSG = _("{updated}   {info} [{size}]")

REMOVING_HEAD = color(_("Removing:"), "RED")
UNPACKING_HEAD = color(_("Unpacking:"), "GREEN")
SETTING_UP_HEAD = color(_("Setting up:"), "GREEN")
PROCESSING_HEAD = color(_("Processing:"), "GREEN")

# NOTE: Spacing of following status messages
# NOTE: is to allow dpkg messages to be properly aligned
# NOTE: Especially if your status would come after the package
# NOTE: You do not have to follow this scheme
# NOTE: but do note that the headers will be colored regardless
# NOTE: Unpacking:   neofetch (7.1.0-3)
# NOTE: Setting up:  neofetch (7.1.0-3)
# NOTE: Removing:    neofetch (7.1.0-3)
# NOTE: Processing:  triggers for man-db (2.10.2-1)
# NOTE: You can change the headers and positions as you would like,
# NOTE: but do note that the headers will be colored regardless
SETTING_UP_MSG = _("{setting_up} {dpkg_msg}")
PROCESSING_MSG = _("{processing} {dpkg_msg}")
UNPACKING_MSG = _("{unpacking}  {dpkg_msg}")
# NOTE: That's the end of alignment spacing
REMOVING_MSG = _("{removing}   {dpkg_msg}")

# NOTE: This translation is separate from the one below
# NOTE: Because we do a check specifically on this string
FETCHED = _("Fetched")
# NOTE: Fetched 81.0 MB in 6s (1448 kB/s)
FETCHED_MSG = _("{fetched} {size} in {elapsed} ({speed}/s)")


class OpProgress(text.OpProgress):
	"""Operation progress reporting.

	This closely resembles OpTextProgress in libapt-pkg.
	"""

	# we have to use this string format or else things get buggy
	# pylint: disable=consider-using-f-string
	def update(self, percent: float | None = None) -> None:
		"""Call periodically to update the user interface."""
		base.OpProgress.update(self, percent)
		if arguments.verbose:
			if self.major_change and self.old_op:
				self._write(self.old_op)

			self._write("%s... %i%%\r" % (self.op, self.percent), False, True)
			self.old_op = self.op

	def done(self) -> None:
		"""Call once an operation has been completed."""
		base.OpProgress.done(self)
		if arguments.verbose:
			if self.old_op:
				self._write(_("%c%s... Done") % ("\r", self.old_op), True, True)
			self.old_op = ""


class UpdateProgress(text.AcquireProgress):
	"""Class for getting cache update status and printing to terminal."""

	def __init__(self, live: DpkgLive) -> None:
		"""Class for getting cache update status and printing to terminal."""
		dprint("Init UpdateProgress")
		text.AcquireProgress.__init__(self)
		self._file = sys.__stdout__
		self._signal: HANDLER = None
		self._id = 1
		self._width = 80
		self.live = live
		self.elapsed = 0.0

		self.live.scroll_list.clear()

	def apt_write(self, msg: str, newline: bool = True, maximize: bool = False) -> None:
		"""Write original apt update message."""
		self._file.write("\r")
		self._file.write(msg)

		# Fill remaining stuff with whitespace
		if self._width > len(msg):
			self._file.write((self._width - len(msg)) * " ")
		elif maximize:  # Needed for OpProgress.
			self._width = max(self._width, len(msg))
		if newline:
			self._file.write("\n")
		else:
			self._file.flush()

	def _write(self, msg: str, newline: bool = True, maximize: bool = False) -> None:
		"""Write the message on the terminal, fill remaining space."""
		if arguments.raw_dpkg or not term.can_format():
			self.apt_write(msg, newline, maximize)
			return

		for item in (UPDATED, DOWNLOADED, IGNORED, NO_CHANGE):
			if item in msg:
				self.table_print(msg, update_spinner=True)
				break
		else:
			if FETCHED in msg:
				self.table_print(msg, fetched=True)
				return

			if ERROR_PREFIX in msg:
				for line in msg.splitlines():
					update_error.append(line)
					eprint(line)
				return

			spinner.text = from_ansi(msg)
			self.table_print(update_spinner=True)

	def table_print(
		self, msg: str = "", fetched: bool = False, update_spinner: bool = False
	) -> None:
		"""Update wrapper for the scroll bar."""
		if not arguments.scroll and not fetched and msg:
			print(msg)
			return

		self.live.scroll_bar(
			msg,
			update_spinner=update_spinner,
			apt_fetch=self.live.install,
			use_bar=False,
		)

	def ims_hit(self, item: apt_pkg.AcquireItemDesc) -> None:
		"""Call when an item is update (e.g. not modified on the server)."""
		base.AcquireProgress.ims_hit(self, item)
		line = NO_CHANGE_MSG.format(
			no_change=color(NO_CHANGE, "GREEN"), info=item.description
		)
		if item.owner.filesize:
			line = NO_CHANGE_SIZE_MSG.format(
				no_change=color(NO_CHANGE, "GREEN"),
				info=item.description,
				size=unit_str(item.owner.filesize).strip(),
			)
		self._write(line)

	def fail(self, item: apt_pkg.AcquireItemDesc) -> None:
		"""Call when an item is failed."""
		base.AcquireProgress.fail(self, item)
		if item.owner.status == item.owner.STAT_DONE:
			self._write(
				IGNORED_MSG.format(
					ignored=color(IGNORED, "YELLOW"), info=item.description
				)
			)
		else:
			# This doesn't need to be translated. Just an error dump
			self._write(f"{ERROR_PREFIX} {item.description}\n  {item.owner.error_text}")

	def fetch(self, item: apt_pkg.AcquireItemDesc) -> None:
		"""Call when some of the item's data is fetched."""
		base.AcquireProgress.fetch(self, item)
		# It's complete already (e.g. Hit)
		if item.owner.complete:
			return
		line = UPDATE_MSG.format(
			updated=color(DOWNLOADED if self.live.install else UPDATED, "BLUE"),
			info=item.description,
		)
		if item.owner.filesize:
			line = UPDATE_SIZE_MSG.format(
				updated=color(DOWNLOADED if self.live.install else UPDATED, "BLUE"),
				info=item.description,
				size=unit_str(item.owner.filesize).strip(),
			)
		self._write(line)

	def _winch(self, *_args: object) -> None:
		"""Signal handler for window resize signals."""
		if hasattr(self._file, "fileno") and os.isatty(self._file.fileno()):
			buf = fcntl.ioctl(self._file, termios.TIOCGWINSZ, 8 * b" ")
			dummy, columns, dummy, dummy = struct.unpack("hhhh", buf)
			self._width = columns - 1  # 1 for the cursor

	def start(self) -> None:
		"""Start an Acquire progress.

		In this case, the function sets up a signal handler for SIGWINCH, i.e.
		window resize signals. And it also sets id to 1.
		"""
		base.AcquireProgress.start(self)
		self.elapsed = time()
		self._signal = signal.signal(signal.SIGWINCH, self._winch)
		# Get the window size.
		self._winch()
		self._id = 1

	def final_msg(self) -> str:
		"""Print closing fetched message."""
		elapsed_time = int(time() - self.elapsed)
		if elapsed_time == 0:
			fetched_speed = self.current_cps
		else:
			fetched_speed = int(self.fetched_bytes) / elapsed_time
		return color(
			FETCHED_MSG.format(
				fetched=FETCHED,
				size=unit_str(int(self.fetched_bytes)).strip(),
				elapsed=apt_pkg.time_to_str(elapsed_time),
				speed=to_str(int(fetched_speed), 1000).strip(),
			)
		)

	def stop(self) -> None:
		"""Invoke when the Acquire process stops running."""
		base.AcquireProgress.stop(self)
		# We don't want to display fetched Zero if we're in apt fetch.
		if self.fetched_bytes != 0 or not self.live.install:
			self._write(self.final_msg())
		# Delete the signal again.
		signal.signal(signal.SIGWINCH, self._signal)

	def pulse(  # pylint: disable=too-many-branches
		self, owner: apt_pkg.Acquire
	) -> bool:
		"""Periodically invoked while the Acquire process is underway."""
		base.AcquireProgress.pulse(self, owner)
		# only show progress on a tty to not clutter log files etc
		if hasattr(self._file, "fileno") and not os.isatty(self._file.fileno()):
			return True

		# calculate progress
		percent = ((self.current_bytes + self.current_items) * 100.0) / (
			self.total_bytes + self.total_items
		)

		shown = False
		tval = f"{percent:.0f}%"
		end = ""
		if self.current_cps:
			eta = int((self.total_bytes - self.current_bytes) / self.current_cps)
			end = f" {unit_str(int(self.current_cps)).strip()}/s {apt_pkg.time_to_str(eta)}"

		for worker in owner.workers:
			val = ""
			if not worker.current_item:
				if worker.status:
					val = f" [{worker.status}]"
					if len(tval) + len(val) + len(end) >= self._width - 5:
						break
					tval += val
					shown = True
				continue
			shown = True

			if worker.current_item.owner.id:
				val += (
					f" [{worker.current_item.owner.id} {worker.current_item.shortdesc}"
				)
			else:
				val += f" [{worker.current_item.description}"
			if worker.current_item.owner.active_subprocess:
				val += f" {worker.current_item.owner.active_subprocess}"

			val += f" {unit_str(worker.current_size).strip()}"

			# Add the total size and percent
			if worker.total_size and not worker.current_item.owner.complete:
				val += (
					f"/{unit_str(worker.total_size).strip()}"
					f" {(worker.current_size * 100.0) / worker.total_size:.0f}%"
				)

			val += "]"

			if len(tval) + len(val) + len(end) >= self._width - 5:
				# Display as many items as screen width
				break
			tval += val

		if not shown:
			tval += _(" [Working]")

		if self.current_cps:
			tval += (self._width - len(end) - len(tval) - 5) * " " + end

		self._write(tval, False)
		return True


# pylint: disable=too-many-instance-attributes, too-many-public-methods, too-many-arguments, too-many-lines
class InstallProgress(base.InstallProgress):
	"""Class for getting dpkg status and printing to terminal."""

	def __init__(
		self,
		dpkg_log: TextIO,
		term_log: TextIO,
		live: DpkgLive,
		task: TaskID,
		config_purge: tuple[str, ...],
	) -> None:
		"""Class for getting dpkg status and printing to terminal."""
		dprint("Init InstallProgress")
		base.InstallProgress.__init__(self)
		self.task = task
		self._dpkg_log = dpkg_log
		self._term_log = term_log
		self.live = live
		self.config_purge = config_purge
		self.raw = False
		self.last_line = b""
		self.child: AptExpect
		self.child_fd: int
		self.child_pid: int
		# Setting environment to xterm seems to work fine for linux terminal
		# I don't think we will be supporting much more this this, at least for now
		if not term.is_xterm() and not arguments.raw_dpkg:
			os.environ["TERM"] = "xterm"

	def finish_update(self) -> None:
		"""Call when update has finished."""
		if not arguments.raw_dpkg:
			dpkg_progress.advance(self.task)
			self.live.scroll_bar()

	def run_install(self, apt: apt_pkg.PackageManager | list[str]) -> int:
		"""Install using the `PackageManager` object `obj`.

		returns the result of calling `obj.do_install()`
		"""
		dprint("Forking")
		pid, self.child_fd = fork()
		if pid == 0:
			try:
				# PEP-446 implemented in Python 3.4 made all descriptors
				# CLOEXEC, but we need to be able to pass writefd to dpkg
				# when we spawn it
				os.set_inheritable(self.writefd, True)
				if not isinstance(apt, apt_pkg.PackageManager):
					# pylint: disable=subprocess-run-check
					self.dpkg_log("Command Execution:\n")
					self.dpkg_log(f"Command = {apt}\n\n")
					os._exit(
						os.spawnlp(  # nosec
							os.P_WAIT,
							"dpkg",
							"dpkg",
							"--status-fd",
							f"{self.write_stream.fileno()}",
							"-i",
							*apt,
						)
					)
				# We ignore this with mypy because the attr is there
				self.dpkg_log("Apt Do Install\n\n")
				os._exit(apt.do_install(self.write_stream.fileno()))  # type: ignore[attr-defined]
			# We need to catch every exception here.
			# If we don't the code continues in the child,
			# And bugs will be very confusing
			except Exception:  # pylint: disable=broad-except
				exception = format_exception(*sys.exc_info())
				self.dpkg_log(f"{exception}\n")
				os._exit(1)

		dprint("Dpkg Forked")
		self.child_pid = pid
		if arguments.raw_dpkg:
			return os.WEXITSTATUS(self.wait_child())
		# We use fdspawn from pexpect to interact with our dpkg pty
		# But we also subclass it to give it the interact method and setwindow
		self.child = AptExpect(self.child_fd, timeout=None)

		signal.signal(signal.SIGWINCH, self.sigwinch_passthrough)
		self.child.interact(self)
		return os.WEXITSTATUS(self.wait_child())

	def sigwinch_passthrough(
		self, _sig_dummy: int, _data_dummy: FrameType | None
	) -> None:
		"""Pass through sigwinch signals to dpkg."""
		buffer = struct.pack("HHHH", 0, 0, 0, 0)
		term_size = struct.unpack(
			"hhhh", fcntl.ioctl(term.STDIN, termios.TIOCGWINSZ, buffer)
		)
		if self.child.isalive():
			with contextlib.suppress(ValueError):
				_setwinsize(self.child_fd, term_size[0], term_size[1])

	def dpkg_log(self, msg: str) -> None:
		"""Write to dpkg-debug.log and flush."""
		self._dpkg_log.write(msg)
		self._dpkg_log.flush()

	def term_log(self, msg: bytes) -> None:
		"""Write to term.log and flush."""
		self._term_log.write(f"{msg.decode('utf-8').strip()}\n")
		self._term_log.flush()

	def dpkg_status(self, data: bytes) -> bool:
		"""Handle any status messages."""
		for status in DPKG_STATUS:
			if status in data:
				statuses = data.split(b"\r")
				if len(statuses) > 2:
					self.dpkg_log(f"Status_Split = {repr(statuses)}\n")
				for msg in statuses:
					if msg != b"":
						spinner.text = from_ansi(color(msg.decode().strip()))
						self.live.scroll_bar(update_spinner=True)
				self.dpkg_log(term.LF.decode())
				return True
		return False

	def read_status(self) -> None:
		"""Read the status fd and send it to update progress bar."""
		try:
			status = self.status_stream.read(1024)
		except OSError as err:
			# Resource temporarily unavailable is ignored
			if err.errno not in (errno.EAGAIN, errno.EWOULDBLOCK):
				print(err.strerror)
			return
		for line in status.splitlines():
			self.update_progress_bar(line)

	def update_progress_bar(self, line: str) -> None:
		"""Update the interface."""
		pkgname = status = status_str = _percent = base_status = ""

		if line.startswith("pm"):
			try:
				(status, pkgname, _percent, status_str) = line.split(":", 3)
			except ValueError:
				# Silently ignore lines that can't be parsed
				return
		elif line.startswith("status"):
			try:
				(base_status, pkgname, status, status_str) = line.split(":", 3)
			except ValueError:
				(base_status, pkgname, status) = line.split(":", 2)

		# Always strip the status message
		pkgname = pkgname.strip()
		status_str = status_str.strip()
		status = status.strip()

		# This is the main branch for apt installs
		if status == "pmstatus":
			dprint(f"apt: {pkgname} {status_str}")
			if status_str.startswith(("Unpacking", "Removing")):
				unpacked.add(pkgname)
				self.advance_progress()
			# Either condition can satisfy this mark provided the package hasn't been advanced
			elif (
				status_str.startswith(("Installed", "Configuring"))
				and pkgname not in pkgnames
				and pkgname in unpacked
			):
				pkgnames.add(pkgname)
				self.advance_progress()
		# This branch only happens for local .deb installs.
		elif base_status == "status":
			# Sometimes unpacked is notified twice for one package
			# We check against out set to make sure not to over shoot progress
			if status == "unpacked" and pkgname not in pkgnames:
				self.advance_progress()
				pkgnames.add(pkgname)
			# Sometimes packages are notified as installed
			# But we only care for ones that have been unpacked
			if status == "installed" and pkgname in pkgnames:
				self.advance_progress()
			dprint(f"dpkg: {pkgname} {status}")

	def pre_filter(self, data: bytes) -> None:
		"""Filter data from interact."""
		self.read_status()

		# This is a work around for a hang in non-interactive mode
		# https://github.com/liske/needrestart/issues/129
		if (
			os.environ.get("DEBIAN_FRONTEND") == "noninteractive"
			and b"[Return]" in data
		):
			os.write(self.child_fd, term.CRLF)

		# Save Term and Alt Screen for debconf and Bracked Paste for the start of the shell
		if (
			term.SAVE_TERM in data
			or term.ENABLE_BRACKETED_PASTE in data
			or term.ENABLE_ALT_SCREEN in data
		):
			self.raw_init()

		self.dpkg_log(f"Raw = {self.raw}: [{repr(data)}]\n")

		if not self.raw and self.dpkg_status(data):
			return
		self.format_dpkg_output(data)

	def format_dpkg_output(self, rawline: bytes) -> None:
		"""Facilitate what needs to happen to dpkg output."""
		# If we made it here that means we're okay to start a new line in the log
		self.dpkg_log(term.LF.decode())

		if self.raw:
			self.rawline_handler(rawline)
			return

		line = ascii_replace(rawline.decode().strip())

		if check_line_spam(line, rawline, self.last_line):
			return

		if (
			self.config_purge
			and "Purging configuration files" in line
			and any(pkg in line for pkg in self.config_purge)
		):
			self.advance_progress()

		self.term_log(rawline)

		# We need to split up large lines and handle them individually.
		if rawline.count(b"\r\n") > 1:
			data_split = rawline.strip().split(b"\r\n")
			error = data_split[0].decode() in dpkg_error
			self.dpkg_log(f"Data_Split = {repr(data_split)}\n")
			for new_line in data_split:
				if not new_line:
					continue
				check_error(rawline, new_line.decode(), error)
				self.format_write(new_line.decode(), rawline)
			return
		self.format_write(line, rawline)

	def format_write(self, line: str, rawline: bytes) -> None:
		"""Format the line if and handle writing it to the terminal."""
		# Main format section for making things pretty
		msg = msg_formatter(line)
		# If verbose we just send it. No bars
		if not arguments.scroll:
			print(msg)
			self.live.scroll_bar()
		elif "Fetched:" in msg:
			# This is some magic for apt-listdifferences to put
			# the fetched message in the spinner since it gets spammy
			spinner.text = from_ansi(color(" ".join(line.split()[1:])))
			self.live.scroll_bar(msg, update_spinner=True)
		else:
			self.live.scroll_bar(msg)
		if (stdout := sys.stdout) is not None:
			stdout.flush()
		self.set_last_line(rawline)

	def rawline_handler(self, rawline: bytes) -> None:
		"""Handle text operations for rawline."""
		term.write(rawline)
		# Once we write we can check if we need to pop out of raw mode
		if (
			term.RESTORE_TERM in rawline
			or term.DISABLE_ALT_SCREEN in rawline
			# Fix for Dialog Debconf Frontend https://gitlab.com/volian/nala/-/issues/211
		) and term.ENABLE_ALT_SCREEN not in rawline:
			self.raw = False
			term.restore_mode()
			self.live.start()
		self.set_last_line(rawline)

	def set_last_line(self, rawline: bytes) -> None:
		"""Set the current line to last line if there is no backspace."""
		# When at the conf prompt if you press Y, then backspace, then hit enter
		# Things get really buggy so instead we check for a backspace
		if term.BACKSPACE not in rawline:
			self.last_line = rawline

	def advance_progress(self) -> None:
		"""Advance the dpkg progress bar."""
		dpkg_progress.advance(self.task)
		if not arguments.scroll:
			self.live.update(
				Panel.fit(
					dpkg_progress.get_renderable(),
					border_style="bold green",
					padding=(0, 0),
				),
				refresh=True,
			)

	def raw_init(self) -> None:
		"""Initialize raw terminal output."""
		if self.raw:
			return
		self.live.raw_init()
		term.set_raw()
		self.raw = True


def check_line_spam(line: str, rawline: bytes, last_line: bytes) -> bool:
	"""Check for, and handle, notices and spam."""
	for message in NOTICES:
		if message in rawline and line not in notice:
			notice.append(line)
			return False
	if b"but it can still be activated by:" in last_line:
		notice.append(f"  {line}")
		return False

	return any(item in line for item in SPAM)


def check_error(data: bytes, line: str, error_in_list: bool = False) -> None:
	"""Check dpkg errors and store them if we need too."""
	# Check so we don't duplicate error messages
	if error_in_list:
		return
	for error in DPKG_ERRORS:
		# Make sure that the error is not spam
		if error in data and all(item not in line for item in SPAM):
			dpkg_error.append(line)


def paren_color(match: Match[str]) -> str:
	"""Color parenthesis."""
	return color("(") if match.group(0) == "(" else color(")")


def line_replace(line: str, header: str) -> str:
	"""Replace wrapper for removing header."""
	return line.replace(header, "").strip()


def format_version(match: list[str], line: str) -> str:
	"""Format version numbers."""
	for ver in match:
		version = ver[1:-1]
		if version and version[0].isdigit():
			new_ver = ver.replace(version, color(version, "BLUE"))
			new_ver = re.sub(PARENTHESIS_PATTERN, paren_color, new_ver)
			line = line.replace(ver, new_ver)
	return line


def fill_pulse(pulse: list[str]) -> str:
	"""Fill the pulse message."""
	last = len(pulse) - 1
	fill = sum(len(line) for line in pulse) + last
	# Set fill width to fit inside the rich panel
	fill = (term.columns - fill) - 5 if arguments.verbose else (term.columns - fill) - 7
	# Minus 2 more to account for ascii simpleDots on the spinner
	if term.console.options.ascii_only:
		fill -= 2
	# Make sure we insert the filler in the right spot
	# In case of extra 1 min as shown below.
	# ['2407', 'kB/s', '30s']
	# ['895', 'kB/s', '1min', '18s']
	index = last - 2
	if "/s" in pulse[index]:
		index = last - 3

	pulse.insert(index, " " * fill)
	return " ".join(pulse)


def msg_formatter(line: str) -> str:
	"""Format dpkg output."""
	if line.endswith("..."):
		line = line.replace("...", "")

	if line.startswith(REMOVING):
		line = REMOVING_MSG.format(
			removing=REMOVING_HEAD, dpkg_msg=line_replace(line, REMOVING)
		)
	elif line.startswith(UNPACKING):
		line = UNPACKING_MSG.format(
			unpacking=UNPACKING_HEAD, dpkg_msg=line_replace(line, UNPACKING)
		)
	elif line.startswith(SETTING_UP):
		line = SETTING_UP_MSG.format(
			setting_up=SETTING_UP_HEAD, dpkg_msg=line_replace(line, SETTING_UP)
		)
	elif line.startswith(PROCESSING):
		line = PROCESSING_MSG.format(
			processing=PROCESSING_HEAD, dpkg_msg=line_replace(line, PROCESSING)
		)
	elif line.startswith(GET):
		line = f"{color(f'{FETCHED}:', 'BLUE')} {' '.join(line.split()[1:])}"

	if match := re.findall(VERSION_PATTERN, line):
		return format_version(match, line)
	return line


class DpkgLive(Live):
	"""Subclass for dpkg live display."""

	def __init__(self, install: bool = True) -> None:
		"""Subclass for dpkg live display."""
		super().__init__(auto_refresh=False, refresh_per_second=4)
		self.install = install
		self.scroll_list: list[str] = []
		self.scroll_config = (False, False, True)
		self.used_scroll: bool = False

	def __enter__(self) -> DpkgLive:
		"""Start the live display."""
		self.start(refresh=self._renderable is not None)
		return self

	def scroll_bar(  # pylint: disable=too-many-arguments
		self,
		msg: str = "",
		apt_fetch: bool = False,
		update_spinner: bool = False,
		use_bar: bool = True,
		rerender: bool = False,
	) -> None:
		"""Print msg to our scroll bar live display."""
		if rerender:
			if not self.used_scroll:
				return
			apt_fetch, update_spinner, use_bar = self.scroll_config
		else:
			self.used_scroll = True
			self.scroll_config = (apt_fetch, update_spinner, use_bar)

		if msg:
			self.scroll_list.append(msg)
		self.slice_list()

		table = Table.grid()
		table.add_column(no_wrap=True, width=term.columns, overflow=OVERFLOW)

		for item in self.scroll_list:
			table.add_row(from_ansi(item))

		if use_bar or update_spinner:
			table.add_row(
				Panel(
					self.get_group(update_spinner, use_bar),
					padding=(0, 0),
					border_style="bold blue" if arguments.scroll else "bold green",
				)
			)

		# We don't need to build the extra panel if we're not scrolling
		if not arguments.scroll:
			self.update(table, refresh=True)
			return

		self.update(
			Panel(
				table,
				title=self.get_title(self.install, apt_fetch),
				title_align="left",
				padding=(0, 0),
				border_style="bold green",
			),
			refresh=True,
		)

	@staticmethod
	def get_title(install: bool, apt_fetch: bool) -> str:
		"""Get the title for our panel."""
		msg = "[bold default]"
		if arguments.command and install and not apt_fetch:
			if arguments.command in ("remove", "purge", "autoremove", "autopurge"):
				if arguments.is_purge():
					msg += _("Purging Packages")
				else:
					msg += _("Removing Packages")
			elif arguments.command in ("upgrade", "dist-upgrade", "full-upgrade"):
				msg += _("Updating Packages")
			elif arguments.command == "install":
				msg += _("Installing Packages")
			elif arguments.command == "history":
				title = (
					_("History Undo")
					if arguments.history == "undo"
					else _("History Redo")
				)
				msg += f"{title} {arguments.history_id}"
			return msg
		if install and apt_fetch:
			return msg + _("Fetching Missed Packages")
		if not arguments.command and arguments.fix_broken:
			return msg + _("Fixing Broken Packages")
		return msg + _("Updating Package List")

	@staticmethod
	def get_group(update_spinner: bool, use_bar: bool) -> RenderableType:
		"""Get the group for our panel."""
		if spinner.text.plain and update_spinner:  # type: ignore[union-attr]
			return (
				Group(spinner, dpkg_progress.get_renderable()) if use_bar else spinner
			)

		return dpkg_progress.get_renderable()

	def slice_list(self) -> None:
		"""Set scroll bar to take up only 1/2 of the screen."""
		scroll_lines = term.lines // 2
		size = len(self.scroll_list)
		if size > scroll_lines and size > 10:
			total = size - max(scroll_lines, 10)
			self.scroll_list = self.scroll_list[total:]

	def raw_init(self) -> None:
		"""Set up the live display to be stopped."""
		# Stop the live display from Auto Refreshing
		if self._refresh_thread:
			self._refresh_thread.stop()
			self._refresh_thread = None

		# We update the live display to blank before stopping it
		self.update("", refresh=True)
		self.stop()

	def start(self, refresh: bool = False) -> None:
		"""Start live rendering display.

		Args:
		----
		        refresh (bool, optional): Also refresh. Defaults to False.

		"""
		with self._lock:
			if self._started:
				return
			self.console.set_live(self)
			self._started = True
			if self._screen:
				self._alt_screen = self.console.set_alt_screen(True)
			self.console.show_cursor(False)
			self._enable_redirect_io()
			self.console.push_render_hook(self)
			if refresh:
				self.refresh()
			if self.auto_refresh:
				self._refresh_thread = Thread(self, self.refresh_per_second)
				self._refresh_thread.start()


def fork() -> tuple[int, int]:
	"""Fork pty or regular."""
	return (os.fork(), 0) if arguments.raw_dpkg else pty.fork()


class AptExpect(fdspawn):  # type: ignore[misc,type-arg,arg-type]
	"""Subclass of fdspawn to add the interact method."""

	def interact(self, install_progress: InstallProgress) -> None:
		"""Hacked up interact method.

		Because pexpect doesn't want to have one for fdspawn.

		This gives control of the child process to the interactive user (the
		human at the keyboard). Keystrokes are sent to the child process, and
		the stdout and stderr output of the child process is printed. This
		simply echos the child stdout and child stderr to the real stdout and
		it echos the real stdin to the child stdin.
		"""
		# Flush the buffer.
		self.write_to_stdout(self.buffer)
		self.stdout.flush()
		self._buffer = self.buffer_type()  # type: ignore[operator]

		_setwinsize(self.child_fd, term.lines, term.columns)

		self.interact_copy(install_progress)

	def interact_copy(self, install_progress: InstallProgress) -> None:
		"""Interact with the pty."""
		while self.isalive():
			try:
				ready = poll_ignore_interrupts([self.child_fd, term.STDIN])
				if self.child_fd in ready and not self._read(install_progress):
					break
				if term.STDIN in ready:
					self._write(install_progress)
			except KeyboardInterrupt:
				term.write(term.CURSER_UP + term.CLEAR_LINE)
				eprint(
					_("{warning} Quitting now could break your system!").format(
						warning=WARNING_PREFIX
					)
				)
				eprint(color(_("Ctrl+C twice quickly will exit") + ELLIPSIS, "RED"))
				sleep(0.5)

	def _read(self, install_progress: InstallProgress) -> bool:
		"""Read data from the pty and send it for formatting."""
		try:
			# We need to read 4096 as sometimes dpkg gives us a lot of lines at one time.
			data = os.read(self.child_fd, 4096)
		except OSError as err:
			if err.args[0] == errno.EIO:
				# Linux-style EOF
				return False
			raise
		if data == b"":
			# BSD-style EOF
			return False
		install_progress.pre_filter(data)
		return True

	def _write(self, install_progress: InstallProgress) -> None:
		"""Write user inputs into the pty."""
		data = os.read(term.STDIN, 1000)
		# Term up and clear in case we answer a question. This stops some live window artifacts.
		# We need to not do this if we're in raw mode or else it breaks things.
		if not install_progress.raw:
			term.write((term.CURSER_UP + term.CLEAR_LINE) * 2)
		while data != b"" and self.isalive():
			split = os.write(self.child_fd, data)
			data = data[split:]
