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"


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 valid Git ref name (usually "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"tasks/{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). Unlike tasks it does not get a ref by default.
The tagger is the author of the comment and the target is the task or parent comment.
Todo: this will not play nice with gc...
"""


class Comment:
	def __init__(
		self,
		target: Oid,  # Task or parent comment OID
		tagger: str,  # Author of the comment
		content: str,
		name: str | None = None,  # "comments/<comment-id>" - Note this will NOT get a ref by default
	):
		if name is None:
			name = f"comments/{randint(1, 2147483647)}"

		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)
		return repo.odb.write(GIT_OBJECT_TAG, raw)


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