import json
from random import randint
from pygit2 import Tag, Oid, reference_is_valid_name, Repository
from datetime import datetime
from json import JSONEncoder
from typing import Any
from enum import Enum

# 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) -> str:
    """Produce a tagger line for git tag object (name <email> timestamp +tz)."""
    if tagger and " <" in tagger and ">" in tagger:
        return tagger + " 0 +0000"
    return (tagger or "distgit <distgit@local>") + " 0 +0000"


def _build_tag_raw(
    target: Oid,
    name: str,
    tagger: str,
    message: str,
    object_type: str = "commit",
) -> bytes:
    """Build full raw git tag object bytes (header + content) for odb.write(4, data). object_type must match the target (e.g. 'commit' or 'tree')."""
    content = (
        f"object {str(target)}\n"
        f"type {object_type}\n"
        f"tag {name}\n"
        f"tagger {_tagger_str(tagger)}\n"
        f"\n{message}"
    )
    body = content.encode("utf-8")
    header = f"tag {len(body)}\0".encode("utf-8")
    return header + body


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.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["title"],
        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["title"],
        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:
    try:
        tag = repo.revparse_single(ref)
    except Exception:
        return None
    if not isinstance(tag, Tag):
        return None
    try:
        j = json.loads(tag.message)
    except (ValueError, TypeError):
        return None
    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