import json
from datetime import datetime
from enum import Enum
from json import JSONEncoder
from random import randint
from typing import Any

from pygit2 import Oid, Repository, Signature, Tag, reference_is_valid_name

# GIT_OBJECT_TAG = 4, GIT_OBJECT_TREE = 2
GIT_OBJECT_TAG = 4
GIT_OBJECT_TREE = 2
EMPTY_TREE_OID_HEX = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"

# All task/board/comment refs live under refs/tags/ so git treats them as real
# references for reachability (git gc, fetch, push). Without the refs/ prefix
# pygit2 will happily create loose files outside of refs/, which git itself
# then ignores -- making tag objects unreachable and subject to pruning.
BOARD_REF_PREFIX: str = "refs/tags/boards/"
TASK_REF_PREFIX: str = "refs/tags/tasks/"
COMMENT_REF_PREFIX: str = "refs/tags/comments/"
ARCHIVE_BOARD_NAME: str = "Archive"


def _ensure_empty_tree(repo: Repository) -> None:
	"""Ensure the well-known empty tree object exists in the repo ODB (so tags can point to it)."""
	try:
		repo[Oid(hex=EMPTY_TREE_OID_HEX)]
	except KeyError:
		repo.odb.write(GIT_OBJECT_TREE, b"tree 0\0")


class _DateTimeEncoder(JSONEncoder):
	def default(self, o: Any) -> Any:
		if hasattr(o, "isoformat"):
			return o.isoformat()
		if isinstance(o, Enum):
			return o.value
		return super().default(o)


def _tagger_str(tagger: str | Signature | None) -> str:
	"""Produce a tagger line for git tag object (name <email> timestamp +tz). Accepts str or pygit2.Signature."""
	if isinstance(tagger, Signature):
		return f"{tagger.name} <{tagger.email}> {tagger.time} {tagger.offset}"
	if isinstance(tagger, str):
		if tagger and " <" in tagger and ">" in tagger:
			return tagger + " 0 +0000"
		return (tagger or "pygittools <pygittools@local>") + " 0 +0000"
	return "pygittools <pygittools@local> 0 +0000"


def _build_tag_raw(
	target: Oid | str,
	name: str,
	tagger: str,
	message: str,
	object_type: str = "commit",
) -> bytes:
	"""Build tag object body (content only) for odb.write(4, data).

	libgit2 prepends 'tag <len>\\0'. object_type must match the target (e.g. 'commit' or 'tree').
	"""
	hex_str = str(target) if isinstance(target, Oid) else target
	content = f"object {hex_str}\ntype {object_type}\ntag {name}\ntagger {_tagger_str(tagger)}\n\n{message}"
	return content.encode("utf-8")


def _read_tag_raw(repo: Repository, oid: Oid) -> tuple[Oid, str, str, str]:
	"""Read a tag from ODB; return (target, name, tagger_str, message)."""
	obj = repo[oid]
	if not isinstance(obj, Tag):
		raise ValueError(f"Not a tag: {oid}")
	tagger = obj.tagger
	tagger_str = f"{tagger.name} <{tagger.email}> {tagger.time} {tagger.offset}" if tagger else ""
	return (obj.target, obj.name, tagger_str, obj.message)


class Task:
	"""
	A Task is an annotated tag (a full tag object, although it will get a ref as well).
	The usual fields of a tag (target, name, tagger) are present, but the "message" is a JSON string.
	All the fields seen below are supported, but arbitrary JSON keys may be added. Text fields are usually Markdown.
	The "name" must be a fully-qualified Git ref name (usually "refs/tags/tasks/<task-id>").
	Because the ODB is immutable, the tag will always point to the latest version of the task.
	"""

	class Status(Enum):
		TODO = "TODO"
		IN_PROGRESS = "IN_PROGRESS"
		IN_REVIEW = "IN_REVIEW"
		DONE = "DONE"
		CANCELLED = "CANCELLED"

	class Priority(Enum):
		LOW = "LOW"
		MEDIUM = "MEDIUM"
		HIGH = "HIGH"
		CRITICAL = "CRITICAL"

	def __init__(
		self,
		target: Oid | str,
		name: str,
		tagger: str,
		title: str,
		description: str = "",
		status: Status | None = None,
		priority: Priority | None = None,
		assignee: str | None = None,
		due_date: datetime | None = None,
		tags: str = "",
	):
		if name is None:
			name = f"{TASK_REF_PREFIX}{title.lower().replace(' ', '_')}"

		if not reference_is_valid_name(name):
			raise ValueError(f"Invalid task backend name: '{name}'")

		self.target = target
		self.tagger = tagger or ""
		self.name = name
		self.title = title
		self.description = description
		self.status = status
		self.priority = priority
		self.assignee = assignee
		self.due_date = due_date
		self.tags = tags
		self.created_at = datetime.now()
		self.comments: list[Oid | str] = []  # Comment OIDs
		self.update_message()

	def update_message(self) -> None:
		self.updated_at = datetime.now()
		self.message = _DateTimeEncoder().encode({
			k: v for k, v in self.__dict__.items() if k not in ["target", "tagger", "name", "message"]
		})

	def write(self, repo: Repository) -> Oid:
		"""
		Write the task to the repository.
		@param repo: The repository to write to.
		@return: The OID of the written task.
		"""
		try:
			obj = repo[self.target]
			object_type = (getattr(obj, "type_str", None) or "commit").lower()
		except KeyError:
			if str(self.target) == EMPTY_TREE_OID_HEX:
				_ensure_empty_tree(repo)
				object_type = "tree"
			else:
				object_type = "commit"
		raw = _build_tag_raw(self.target, self.name, self.tagger, self.message, object_type=object_type)
		oid = repo.odb.write(GIT_OBJECT_TAG, raw)
		repo.references.create(self.name, oid, force=True)
		return oid


def _parse_dt(s):
	if not s:
		return None
	if hasattr(s, "isoformat"):
		return s
	return datetime.fromisoformat(str(s).replace("Z", "+00:00")) if s else None


def get_task(repo: Repository, ref: str) -> Task | None:
	tag = repo.revparse_single(ref)
	if not isinstance(tag, Tag):
		raise ValueError(f"Requested task is not a tag: {ref}")
	j = json.loads(tag.message)
	status = Task.Status(j["status"]) if j.get("status") else None
	priority = Task.Priority(j["priority"]) if j.get("priority") else None
	t = Task(
		tag.target,
		tag.name,
		str(tag.tagger),
		j.get("title", "Untitled"),
		description=j.get("description", ""),
		status=status,
		priority=priority,
		assignee=j.get("assignee"),
		due_date=_parse_dt(j.get("due_date")),
		tags=j.get("tags", "") or "",
	)
	t.comments = j.get("comments", [])
	t.created_at = _parse_dt(j.get("created_at")) or t.created_at
	t.updated_at = _parse_dt(j.get("updated_at")) or t.updated_at
	return t


def get_task_by_oid(repo: Repository, oid: Oid) -> Task | None:
	"""Load a task by its tag OID (e.g. from board.tasks)."""
	obj = repo[oid]
	if not isinstance(obj, Tag):
		raise ValueError(f"Object is not a task tag: {oid}")
	j = json.loads(obj.message)
	status = Task.Status(j["status"]) if j.get("status") else None
	priority = Task.Priority(j["priority"]) if j.get("priority") else None
	t = Task(
		obj.target,
		obj.name,
		str(obj.tagger),
		j.get("title", "Untitled"),
		description=j.get("description", ""),
		status=status,
		priority=priority,
		assignee=j.get("assignee"),
		due_date=_parse_dt(j.get("due_date")),
		tags=j.get("tags", "") or "",
	)
	t.comments = j.get("comments", [])
	t.created_at = _parse_dt(j.get("created_at")) or t.created_at
	t.updated_at = _parse_dt(j.get("updated_at")) or t.updated_at
	return t


class Comment:
	"""
	A Comment is an annotated tag (a full tag object) that also gets a ref under refs/tags/comments/.
	The tagger is the author of the comment and the target is the task or parent comment.
	The ref keeps the comment reachable so git gc will not prune it.
	"""

	def __init__(
		self,
		target: Oid,  # Task or parent comment OID
		tagger: str,  # Author of the comment
		content: str,
		name: str | None = None,  # Fully-qualified ref, e.g. "refs/tags/comments/<comment-id>"
	):
		if name is None:
			name = f"{COMMENT_REF_PREFIX}{randint(1, 2147483647)}"

		if not reference_is_valid_name(name):
			raise ValueError(f"Invalid comment backend name: '{name}'")

		self.target = target
		self.tagger = tagger or ""
		self.name = name
		self.content = content
		self.created_at = datetime.now()
		self.edited_at = self.created_at
		self.update_message()

	def update_message(self) -> None:
		self.updated_at = datetime.now()
		self.message = _DateTimeEncoder().encode({
			k: v for k, v in self.__dict__.items() if k not in ["target", "tagger", "name", "message"]
		})

	def write(self, repo: Repository) -> Oid:
		"""
		Write the comment to the repository.
		@param repo: The repository to write to.
		@return: The OID of the written comment.
		"""
		try:
			obj = repo[self.target]
			object_type = (getattr(obj, "type_str", None) or "commit").lower()
		except KeyError:
			if str(self.target) == EMPTY_TREE_OID_HEX:
				_ensure_empty_tree(repo)
				object_type = "tree"
			else:
				object_type = "commit"
		raw = _build_tag_raw(self.target, self.name, self.tagger, self.message, object_type=object_type)
		oid = repo.odb.write(GIT_OBJECT_TAG, raw)
		repo.references.create(self.name, oid, force=True)
		return oid


def get_comment(repo: Repository, oid: Oid) -> Comment:
	obj = repo[oid]
	if not isinstance(obj, Tag):
		raise ValueError(f"Requested comment is not a tag: {oid}")
	j = json.loads(obj.message)
	c = Comment(obj.target, str(obj.tagger), j["content"], obj.name)
	c.created_at = _parse_dt(j.get("created_at")) or c.created_at
	c.edited_at = _parse_dt(j.get("edited_at"))
	return c


class Board:
	"""
	A Board is an annotated tag (a full tag object, although it will get a ref as well).
	The only used field is "message" which is a JSON string.
	The "tasks" field is an array of task OIDs.
	"""

	def __init__(
		self,
		target: Oid | str,
		name: str,
		tagger: str,
		description: str = "",
	):
		self.target = target
		self.name = name
		self.tagger = tagger or ""
		self.description = description
		self.created_at = datetime.now()
		self.updated_at = datetime.now()
		self.tasks: list[Oid | str] = []  # Task OIDs
		self.update_message()

	def update_message(self) -> None:
		self.updated_at = datetime.now()
		self.message = _DateTimeEncoder().encode({
			k: v for k, v in self.__dict__.items() if k not in ["target", "name", "tagger", "message"]
		})

	def write(self, repo: Repository) -> Oid:
		if str(self.target) == EMPTY_TREE_OID_HEX:
			_ensure_empty_tree(repo)
		try:
			obj = repo[self.target]
			object_type = (getattr(obj, "type_str", None) or "commit").lower()
		except KeyError:
			object_type = "tree" if str(self.target) == EMPTY_TREE_OID_HEX else "commit"
		raw = _build_tag_raw(self.target, self.name, self.tagger, self.message, object_type=object_type)
		oid = repo.odb.write(GIT_OBJECT_TAG, raw)
		repo.references.create(self.name, oid, force=True)
		return oid


def get_board(repo: Repository, ref: str) -> Board | None:
	tag = repo.revparse_single(ref)
	if not isinstance(tag, Tag):
		return None
	j = json.loads(tag.message)
	b = Board(tag.target, tag.name, str(tag.tagger), j.get("description", ""))
	b.tasks = j.get("tasks", [])
	b.created_at = _parse_dt(j.get("created_at")) or b.created_at
	b.updated_at = _parse_dt(j.get("updated_at")) or b.updated_at
	return b


def delete_task_local(repo: Repository, task_ref: str) -> None:
	try:
		tag_obj = repo.revparse_single(task_ref)
	except (KeyError, ValueError):
		return
	if not isinstance(tag_obj, Tag):
		return
	task = get_task(repo, task_ref)
	task_name = tag_obj.name

	for ref_name in list(repo.references):
		if not ref_name.startswith(BOARD_REF_PREFIX):
			continue
		board = get_board(repo, ref_name)
		if board is None:
			continue
		original = list(getattr(board, "tasks", []) or [])
		filtered: list[Oid | str] = []
		removed: list[str] = []
		for entry in original:
			try:
				oid = Oid(hex=entry) if isinstance(entry, str) else entry
				board_task = get_task_by_oid(repo, oid)
			except (ValueError, KeyError, TypeError):
				filtered.append(entry)
				continue
			if board_task is None or board_task.name != task_name:
				filtered.append(entry)
			else:
				removed.append(str(oid))
		if not removed:
			continue
		board.tasks = filtered
		board.update_message()
		board.write(repo)

	if task is not None:
		for oid_hex in getattr(task, "comments", []) or []:
			try:
				oid = Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
				comment = get_comment(repo, oid)
			except (ValueError, KeyError, TypeError):
				continue
			if comment.name in repo.references:
				repo.references.delete(comment.name)

	if task_ref in repo.references:
		repo.references.delete(task_ref)


def _remove_task_from_board(repo: Repository, board_ref: str, task_name: str) -> bool:
	board = get_board(repo, board_ref)
	if board is None:
		return False
	original = list(getattr(board, "tasks", []) or [])
	filtered: list[Oid | str] = []
	removed = False
	for entry in original:
		try:
			oid = Oid(hex=entry) if isinstance(entry, str) else entry
			board_task = get_task_by_oid(repo, oid)
		except (ValueError, KeyError, TypeError):
			filtered.append(entry)
			continue
		if board_task is None or board_task.name != task_name:
			filtered.append(entry)
		else:
			removed = True
	if not removed:
		return False
	board.tasks = filtered
	board.update_message()
	board.write(repo)
	return True


def archive_task_local(
	repo: Repository,
	task_ref: str,
	source_board_name: str,
	*,
	archive_board_name: str = ARCHIVE_BOARD_NAME,
) -> str:
	"""Move a task from a board to the archive board, creating the archive board if needed."""
	if task_ref not in repo.references:
		raise KeyError(f"Task not found: {task_ref}")
	task = get_task(repo, task_ref)
	if task is None:
		raise KeyError(f"Task not found: {task_ref}")
	tag_obj = repo.revparse_single(task_ref)
	if not isinstance(tag_obj, Tag):
		raise KeyError(f"Task not found: {task_ref}")

	source_ref = f"{BOARD_REF_PREFIX}{source_board_name}"
	if source_ref not in repo.references:
		raise KeyError(f"Board not found: {source_board_name}")

	_remove_task_from_board(repo, source_ref, tag_obj.name)

	oid_hex = str(repo.references[task_ref].resolve().target)
	archive_ref = f"{BOARD_REF_PREFIX}{archive_board_name}"
	if archive_ref in repo.references:
		archive_board = get_board(repo, archive_ref)
		assert archive_board is not None
		tasks = list(getattr(archive_board, "tasks", []) or [])
		if oid_hex not in tasks:
			tasks.append(oid_hex)
		archive_board.tasks = tasks
		archive_board.update_message()
		archive_board.write(repo)
		return archive_ref

	archive_board = Board(
		Oid(hex=EMPTY_TREE_OID_HEX),
		archive_ref,
		tagger="",
		description="Archived tasks",
	)
	archive_board.tasks = [oid_hex]
	archive_board.update_message()
	archive_board.write(repo)
	return archive_ref