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/"


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 "distgit <distgit@local>") + " 0 +0000"
	return "distgit <distgit@local> 0 +0000"


def _build_tag_raw(
	target: Oid,
	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').
	"""
	content = f"object {str(target)}\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)


"""
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 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,
		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,
	):
		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.created_at = datetime.now()
		self.comments = []  # 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"]
		})

	"""
    Write the task to the repository.
    @param repo: The repository to write to.
    @return: The OID of the written task.
    """

	def write(self, repo: Repository) -> Oid:
		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,
		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")),
	)
	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,
		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")),
	)
	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


"""
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.
"""


class Comment:
	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 = None
		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"]
		})

	"""
    Write the comment to the repository.
    @param repo: The repository to write to.
    @return: The OID of the written comment.
    """

	def write(self, repo: Repository) -> Oid:
		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 | None:
	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, 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


"""
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.
"""


class Board:
	def __init__(
		self,
		target: Oid,
		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 = []  # 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, 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