"""
Formatting: escaping (esc_param, esc_path_info, esc_url, esc_attr, esc_html, esc_path, sanitize),
quot_cec, quot_upr, unquote, untabify, to_utf8, chop_str, chop_and_escape_str, age_class, age_string.
Ported from gitweb/gitweb.perl.
"""

from __future__ import annotations

import html
import re
from urllib.parse import quote, quote_plus

# Fallback encoding when bytes are not valid UTF-8 (gitweb: $fallback_encoding)
FALLBACK_ENCODING = "latin1"

# Control character escape codes (CEC). Port of quot_cec.
_CEC_MAP = {
	"\t": r"\t",
	"\n": r"\n",
	"\r": r"\r",
	"\f": r"\f",
	"\b": r"\b",
	"\a": r"\a",
	"\x1b": r"\e",
	"\v": r"\v",
	"\0": r"\0",
}


def to_utf8(s: str | bytes | None) -> str | None:
	"""Decode to UTF-8 string; use fallback encoding if not valid UTF-8. Port of to_utf8."""
	if s is None:
		return None
	if isinstance(s, str):
		return s
	try:
		return s.decode("utf-8")
	except UnicodeDecodeError:
		return s.decode(FALLBACK_ENCODING, errors="replace")


def esc_param(s: str | None) -> str | None:
	"""URL-encode for query param; keep / and space as +. Port of esc_param."""
	if s is None:
		return None
	return quote_plus(s, safe="")  # gitweb keeps -_.~()/@: and space→+


def esc_path_info(s: str | None) -> str | None:
	"""Path segment encoding; ? must be escaped. Port of esc_path_info."""
	if s is None:
		return None
	# Safe: A-Za-z0-9\-_.~();/;:@&= +
	return quote(s, safe="-_.~();/:@&= ")


def esc_url(s: str | None) -> str | None:
	"""URL encoding for href. Port of esc_url (same idea as esc_param)."""
	if s is None:
		return None
	return quote(s, safe="-_.~()/:@!")


def esc_attr(s: str | None) -> str | None:
	"""Escape for HTML attribute. Port of esc_attr."""
	if s is None:
		return None
	return html.escape(s, quote=True)


def esc_html(s: str | None) -> str | None:
	"""Escape for HTML body. Port of esc_html."""
	if s is None:
		return None
	return html.escape(s, quote=False)


def quot_cec(char: str, nohtml: bool = False) -> str:
	"""Printable representation of control char (CEC). Port of quot_cec."""
	out = _CEC_MAP.get(char, f"\\{ord(char):02x}")
	if nohtml:
		return out
	return f'<span class="cntrl">{out}</span>'


def quot_upr(char: str, nohtml: bool = False) -> str:
	"""Unicode control pictures. Port of quot_upr."""
	code = 0x2400 + ord(char)
	out = f"&#{code};"
	if nohtml:
		return out
	return f'<span class="cntrl">{out}</span>'


def esc_path(s: str | None, nbsp: bool = False) -> str | None:
	"""UTF-8, HTML-escape, then control chars to quot_cec. Port of esc_path."""
	if s is None:
		return None
	s = to_utf8(s) or s
	s = html.escape(s, quote=False)
	if nbsp:
		s = s.replace(" ", "&nbsp;")
	result = []
	for c in s:
		if ord(c) < 32 or ord(c) == 127:
			result.append(quot_cec(c))
		else:
			result.append(c)
	return "".join(result)


def sanitize(s: str | None) -> str | None:
	"""XHTML-safe: control chars to CEC except tab/lf/cr. Port of sanitize."""
	if s is None:
		return None
	s = to_utf8(s) or s
	result = []
	for c in s:
		if c in "\t\n\r":
			result.append(c)
		elif ord(c) < 32 or ord(c) == 127:
			result.append(quot_cec(c, nohtml=True))
		else:
			result.append(c)
	return "".join(result)


def unquote(s: str | None) -> str:
	"""Unescape git-style quoted filenames (C and octal). Port of unquote."""
	if s is None:
		return ""

	def unq(seq: str) -> str:
		es = {
			"t": "\t",
			"n": "\n",
			"r": "\r",
			"f": "\f",
			"b": "\b",
			"a": "\a",
			"e": "\x1b",
			"v": "\v",
		}
		if re.match(r"^[0-7]{1,3}$", seq):
			return chr(int(seq, 8))
		return es.get(seq, seq)

	if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
		s = s[1:-1]
		s = re.sub(r"\\([^0-7]|[0-7]{1,3})", lambda m: unq(m.group(1)), s)
	return s


def untabify(line: str, tabwidth: int = 8) -> str:
	"""Expand tabs to spaces. Port of untabify."""
	result = []
	col = 0
	for c in line:
		if c == "\t":
			n = tabwidth - (col % tabwidth)
			result.append(" " * n)
			col += n
		else:
			result.append(c)
			col += 1
	return "".join(result)


def chop_str(
	s: str,
	length: int,
	add_len: int = 10,
	where: str = "right",
) -> str:
	"""Chop on word boundary between length and length+add_len. Port of chop_str."""
	s = to_utf8(s) or ""
	if where == "center":
		if length + 5 >= len(s):
			return s
		half = length // 2
		endre = re.compile(rf".{{{half}}}\w{{0,{add_len}}}", re.DOTALL)
		begre = re.compile(rf"\w{{0,{add_len}}}.{{{half}}}$", re.DOTALL)
		m1 = re.match(rf"^(.{{{half}}}\w{{0,{add_len}}})(.*)$", s, re.DOTALL)
		if not m1:
			return s[: length // 2] + " ... " + s[-(length // 2) :]
		left, rest = m1.group(1), m1.group(2)
		m2 = re.match(rf"^(.*?)(\w{{0,{add_len}}}.{{{half}}})$", rest, re.DOTALL)
		if not m2:
			return left + " ... " + rest[-half:]
		mid, right = m2.group(1), m2.group(2)
		if len(mid) > 5:
			mid = " ... "
		return left + mid + right
	if length + 4 >= len(s):
		return s
	if where == "left":
		begre = re.compile(rf"\w{{0,{add_len}}}.{{{length}}}$")
		m = begre.search(s)
		if m:
			body = m.group(0)
			lead = s[: m.start()]
			if len(lead) > 4:
				lead = " ..."
			return lead + body
		return s
	# right
	endre = re.compile(rf".{{{length}}}\w{{0,{add_len}}}")
	m = endre.match(s)
	if m:
		body = m.group(0)
		tail = s[m.end() :]
		if len(tail) > 4:
			tail = "... "
		return body + tail
	return s


def chop_and_escape_str(
	s: str,
	length: int,
	add_len: int = 10,
	where: str = "right",
) -> str:
	"""Chop then HTML-escape; wrap in span with title if chopped. Port of chop_and_escape_str."""
	chopped = chop_str(s, length, add_len, where)
	s = to_utf8(s) or s
	if chopped == s:
		return esc_html(chopped) or ""
	title = esc_attr(s.replace("\n", " ").replace("\r", "?"))
	escaped = esc_html(chopped) or ""
	return f'<span title="{title}">{escaped}</span>'


def age_class(age_seconds: float | None) -> str:
	"""CSS class for age. Port of age_class."""
	if age_seconds is None:
		return "noage"
	if age_seconds < 2 * 3600:
		return "age0"
	if age_seconds < 2 * 86400:
		return "age1"
	return "age2"


def age_string(age_seconds: float) -> str:
	"""Human-readable age. Port of age_string."""
	if age_seconds > 2 * 365 * 86400:
		return f"{int(age_seconds / 86400 / 365)} years ago"
	if age_seconds > 2 * (365 / 12) * 86400:
		return f"{int(age_seconds / 86400 / (365 / 12))} months ago"
	if age_seconds > 2 * 7 * 86400:
		return f"{int(age_seconds / 86400 / 7)} weeks ago"
	if age_seconds > 2 * 86400:
		return f"{int(age_seconds / 86400)} days ago"
	if age_seconds > 2 * 3600:
		return f"{int(age_seconds / 3600)} hours ago"
	if age_seconds > 2 * 60:
		return f"{int(age_seconds / 60)} min ago"
	if age_seconds > 2:
		return f"{int(age_seconds)} sec ago"
	return "right now"