from __future__ import annotations

import json
import time
from datetime import datetime
from enum import Enum, StrEnum
from json import JSONEncoder
from random import randint
from typing import Any, TypedDict

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"


# Well-known empty tree OID for boards/tasks when repo has no HEAD
EMPTY_TREE_OID = Oid(hex="4b825dc642cb6eb9a060e54bf8d69288fbee4904")

# Allowed sprint story point values (Fibonacci scale). Anything else is treated as unestimated.
SPRINT_POINTS: tuple[int, ...] = (0, 1, 2, 3, 5, 8, 13, 21)


def get_board_ref(name: str) -> str:
    return f"{BOARD_REF_PREFIX}{name}"


def get_task_ref(task_id: str) -> str:
    return f"{TASK_REF_PREFIX}{task_id}"


def get_repo_head_or_empty(repo: Repository) -> Oid | str:
    try:
        return repo.head.target
    except KeyError:
        return EMPTY_TREE_OID


def parse_points(raw: Any) -> int | None:
    """Parse a story points value, keeping only values on the Fibonacci scale."""
    if raw is None or raw == "":
        return None
    try:
        value = int(raw)
    except (TypeError, ValueError):
        return None
    return value if value in SPRINT_POINTS else None


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(StrEnum):
        TODO = "TODO"
        IN_PROGRESS = "IN_PROGRESS"
        IN_REVIEW = "IN_REVIEW"
        DONE = "DONE"
        CANCELLED = "CANCELLED"

    class Priority(StrEnum):
        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 = "",
        points: int | 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.tags = tags
        self.points = points  # Sprint story points (Fibonacci scale), None = unestimated
        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: str | datetime | None) -> datetime | None:
    if not s:
        return None
    if isinstance(s, datetime):
        return s
    return datetime.fromisoformat(s.replace("Z", "+00:00"))


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 "",
        points=j.get("points"),
    )
    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 "",
        points=j.get("points"),
    )
    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 | str,  # 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)
    created = _parse_dt(j.get("created_at"))
    if not created:
        raise ValueError(f"Invalid creation time for oid: {oid}")
    c.created_at = created
    edited = _parse_dt(j.get("edited_at"))
    if not edited:
        raise ValueError(f"Invalid edit time for oid: {oid}")
    c.edited_at = edited
    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


class CreateTaskResult(TypedDict, total=True):
    id: str
    ref: str
    oid: Oid
    task: Task


def create_task(
    repo: Repository,
    board: Board,
    title: str,
    description: str = "",
    status: Task.Status = Task.Status.TODO,
    priority: Task.Priority = Task.Priority.LOW,
    assignee: str = "",
    due_date: datetime | None = None,
    tags: str = "",
    points: int | None = None,
) -> CreateTaskResult:
    task_id = f"task_{int(time.time() * 1000)}"
    target = get_repo_head_or_empty(repo)
    ref = get_task_ref(task_id)
    task = Task(
        target=target,
        name=ref,
        tagger="",
        title=title,
        description=description,
        status=status,
        priority=priority,
        assignee=assignee or None,
        due_date=due_date,
        tags=tags or "",
        points=parse_points(points),
    )
    oid = task.write(repo)
    board.tasks = getattr(board, "tasks", []) or []
    board.tasks.append(str(oid))
    board.update_message()
    board.write(repo)
    return {"id": task_id, "ref": ref, "oid": oid, "task": task}