"""Obsidian domain entities."""

from __future__ import annotations

from dataclasses import dataclass, field, replace
from datetime import date
from typing import Any

from .enums import CanvasNodeType, NoteState, TaskState
from .exceptions import (
    InvalidCanvasError,
    InvalidLinkError,
    InvalidNoteError,
    InvalidTaskError,
    InvalidTemplateError,
    InvalidVaultError,
)
from .value_objects import Alias, CanvasName, NotePath, NoteTitle, TagName, TaskText, TemplateName, VaultName


@dataclass(frozen=True)
class Link:
    target: NotePath
    alias: Alias | None = None
    is_embed: bool = False
    fragment: str | None = None

    def __post_init__(self) -> None:
        if not isinstance(self.target, NotePath):
            raise InvalidLinkError("target must be a NotePath instance")
        if self.alias is not None and not isinstance(self.alias, Alias):
            raise InvalidLinkError("alias must be an Alias instance or None")
        if isinstance(self.is_embed, bool) is False:
            raise InvalidLinkError("is_embed must be a boolean")
        if self.fragment is not None:
            if not isinstance(self.fragment, str) or not self.fragment.strip():
                raise InvalidLinkError("fragment must be a non-empty string when provided")


@dataclass
class Note:
    title: NoteTitle
    path: NotePath
    content: str = ""
    tags: list[TagName] = field(default_factory=list)
    aliases: list[Alias] = field(default_factory=list)
    links: list[Link] = field(default_factory=list)
    frontmatter: dict[str, Any] = field(default_factory=dict)
    state: NoteState = NoteState.ACTIVE
    vault_name: VaultName | None = None

    def __post_init__(self) -> None:
        if not isinstance(self.title, NoteTitle):
            raise InvalidNoteError("title must be a NoteTitle instance")
        if not isinstance(self.path, NotePath):
            raise InvalidNoteError("path must be a NotePath instance")
        if not isinstance(self.content, str):
            raise InvalidNoteError("content must be a string")
        if any(not isinstance(tag, TagName) for tag in self.tags):
            raise InvalidNoteError("tags must contain TagName instances")
        if any(not isinstance(alias, Alias) for alias in self.aliases):
            raise InvalidNoteError("aliases must contain Alias instances")
        if any(not isinstance(link, Link) for link in self.links):
            raise InvalidNoteError("links must contain Link instances")
        if not isinstance(self.frontmatter, dict):
            raise InvalidNoteError("frontmatter must be a dictionary")
        if not isinstance(self.state, NoteState):
            raise InvalidNoteError("state must be a NoteState")
        if self.vault_name is not None and not isinstance(self.vault_name, VaultName):
            raise InvalidNoteError("vault_name must be a VaultName instance or None")

    def set_content(self, content: str) -> None:
        if not isinstance(content, str):
            raise InvalidNoteError("content must be a string")
        self.content = content

    def add_tag(self, tag: TagName) -> TagName:
        if not isinstance(tag, TagName):
            raise InvalidNoteError("tag must be a TagName instance")
        if tag not in self.tags:
            self.tags.append(tag)
        return tag

    def add_alias(self, alias: Alias) -> Alias:
        if not isinstance(alias, Alias):
            raise InvalidNoteError("alias must be an Alias instance")
        if alias not in self.aliases:
            self.aliases.append(alias)
        return alias

    def add_link(self, link: Link) -> Link:
        if not isinstance(link, Link):
            raise InvalidNoteError("link must be a Link instance")
        if link not in self.links:
            self.links.append(link)
        return link

    def rename(self, title: NoteTitle) -> None:
        if not isinstance(title, NoteTitle):
            raise InvalidNoteError("title must be a NoteTitle instance")
        self.title = title

    def move_to(self, path: NotePath) -> None:
        if not isinstance(path, NotePath):
            raise InvalidNoteError("path must be a NotePath instance")
        self.path = path

    def archive(self) -> None:
        self.state = NoteState.ARCHIVED

    def trash(self) -> None:
        self.state = NoteState.TRASHED

    def restore(self) -> None:
        self.state = NoteState.ACTIVE


@dataclass
class Vault:
    name: VaultName
    notes: list[Note] = field(default_factory=list)
    description: str | None = None

    def __post_init__(self) -> None:
        if not isinstance(self.name, VaultName):
            raise InvalidVaultError("name must be a VaultName instance")
        if self.description is not None and (not isinstance(self.description, str) or not self.description.strip()):
            raise InvalidVaultError("description must be a non-empty string when provided")
        if any(not isinstance(note, Note) for note in self.notes):
            raise InvalidVaultError("notes must contain Note instances")
        for note in self.notes:
            if note.vault_name is None:
                note.vault_name = self.name
            elif note.vault_name != self.name:
                raise InvalidVaultError("note belongs to a different vault")
        self._ensure_unique_paths()

    def _ensure_unique_paths(self) -> None:
        paths = [note.path.value for note in self.notes]
        if len(paths) != len(set(paths)):
            raise InvalidVaultError("vault cannot contain duplicate note paths")

    def _find_index(self, path: NotePath) -> int:
        if not isinstance(path, NotePath):
            raise InvalidVaultError("path must be a NotePath instance")
        for index, note in enumerate(self.notes):
            if note.path == path:
                return index
        raise InvalidVaultError(f"note not found: {path.value}")

    def get_note(self, path: NotePath) -> Note:
        return self.notes[self._find_index(path)]

    def add_note(self, note: Note) -> Note:
        if not isinstance(note, Note):
            raise InvalidVaultError("note must be a Note instance")
        if note.path.value in {existing.path.value for existing in self.notes}:
            raise InvalidVaultError(f"duplicate note path: {note.path.value}")
        note.vault_name = self.name
        self.notes.append(note)
        return note

    def remove_note(self, path: NotePath) -> Note:
        index = self._find_index(path)
        note = self.notes.pop(index)
        note.vault_name = None
        return note

    def move_note(self, path: NotePath, new_path: NotePath) -> Note:
        if not isinstance(new_path, NotePath):
            raise InvalidVaultError("new_path must be a NotePath instance")
        note = self.get_note(path)
        if note.path == new_path:
            return note
        if new_path.value in {note.path.value for note in self.notes}:
            raise InvalidVaultError(f"duplicate note path: {new_path.value}")
        note.move_to(new_path)
        return note

    def archive_note(self, path: NotePath) -> Note:
        note = self.get_note(path)
        note.archive()
        return note

    def trash_note(self, path: NotePath) -> Note:
        note = self.get_note(path)
        note.trash()
        return note

    def restore_note(self, path: NotePath) -> Note:
        note = self.get_note(path)
        note.restore()
        return note

    def search(self, query: str) -> list[Note]:
        if not isinstance(query, str) or not query.strip():
            raise InvalidVaultError("search query must be a non-empty string")
        normalized = query.strip().lower()
        matches: list[Note] = []
        for note in self.notes:
            haystack = " ".join(
                [
                    note.title.value,
                    note.path.value,
                    note.content,
                    " ".join(tag.value for tag in note.tags),
                    " ".join(alias.value for alias in note.aliases),
                    " ".join(str(value) for value in note.frontmatter.values()),
                ]
            ).lower()
            if normalized in haystack:
                matches.append(note)
        return matches

    def find_by_tag(self, tag: TagName) -> list[Note]:
        if not isinstance(tag, TagName):
            raise InvalidVaultError("tag must be a TagName instance")
        return [note for note in self.notes if tag in note.tags]

    def backlinks(self, target: NotePath) -> list[Note]:
        if not isinstance(target, NotePath):
            raise InvalidVaultError("target must be a NotePath instance")
        return [note for note in self.notes if any(link.target == target for link in note.links)]


@dataclass(frozen=True)
class Template:
    name: TemplateName
    body: str
    frontmatter: dict[str, Any] = field(default_factory=dict)
    description: str | None = None

    def __post_init__(self) -> None:
        if not isinstance(self.name, TemplateName):
            raise InvalidTemplateError("name must be a TemplateName instance")
        if not isinstance(self.body, str) or not self.body.strip():
            raise InvalidTemplateError("body must be a non-empty string")
        if not isinstance(self.frontmatter, dict):
            raise InvalidTemplateError("frontmatter must be a dictionary")
        if self.description is not None and (not isinstance(self.description, str) or not self.description.strip()):
            raise InvalidTemplateError("description must be a non-empty string when provided")


@dataclass
class Task:
    text: TaskText
    state: TaskState = TaskState.OPEN
    note_path: NotePath | None = None
    due_date: date | None = None
    completed_at: date | None = None
    tags: list[TagName] = field(default_factory=list)

    def __post_init__(self) -> None:
        if not isinstance(self.text, TaskText):
            raise InvalidTaskError("text must be a TaskText instance")
        if not isinstance(self.state, TaskState):
            raise InvalidTaskError("state must be a TaskState")
        if self.note_path is not None and not isinstance(self.note_path, NotePath):
            raise InvalidTaskError("note_path must be a NotePath instance or None")
        if self.due_date is not None and not isinstance(self.due_date, date):
            raise InvalidTaskError("due_date must be a date or None")
        if self.completed_at is not None and not isinstance(self.completed_at, date):
            raise InvalidTaskError("completed_at must be a date or None")
        if any(not isinstance(tag, TagName) for tag in self.tags):
            raise InvalidTaskError("tags must contain TagName instances")
        if self.state == TaskState.DONE and self.completed_at is None:
            raise InvalidTaskError("done tasks must have a completed_at date")

    def complete(self, completed_at: date | None = None) -> None:
        completed_at = completed_at or date.today()
        if not isinstance(completed_at, date):
            raise InvalidTaskError("completed_at must be a date")
        self.state = TaskState.DONE
        self.completed_at = completed_at

    def reopen(self) -> None:
        self.state = TaskState.OPEN
        self.completed_at = None

    def cancel(self) -> None:
        self.state = TaskState.CANCELLED

    def add_tag(self, tag: TagName) -> TagName:
        if not isinstance(tag, TagName):
            raise InvalidTaskError("tag must be a TagName instance")
        if tag not in self.tags:
            self.tags.append(tag)
        return tag


@dataclass(frozen=True)
class CanvasNode:
    id: str
    kind: CanvasNodeType
    x: int
    y: int
    text: str = ""
    note_path: NotePath | None = None
    member_ids: list[str] = field(default_factory=list)

    def __post_init__(self) -> None:
        if not isinstance(self.id, str) or not self.id.strip():
            raise InvalidCanvasError("node id cannot be empty")
        if not isinstance(self.kind, CanvasNodeType):
            raise InvalidCanvasError("kind must be a CanvasNodeType")
        if isinstance(self.x, bool) or not isinstance(self.x, int):
            raise InvalidCanvasError("x must be an integer")
        if isinstance(self.y, bool) or not isinstance(self.y, int):
            raise InvalidCanvasError("y must be an integer")
        if not isinstance(self.text, str):
            raise InvalidCanvasError("text must be a string")
        if self.note_path is not None and not isinstance(self.note_path, NotePath):
            raise InvalidCanvasError("note_path must be a NotePath instance or None")
        if any(not isinstance(member_id, str) or not member_id.strip() for member_id in self.member_ids):
            raise InvalidCanvasError("member_ids must contain non-empty strings")
        if len(self.member_ids) != len(set(self.member_ids)):
            raise InvalidCanvasError("member_ids cannot contain duplicates")
        if self.kind == CanvasNodeType.GROUP and not self.member_ids:
            raise InvalidCanvasError("group nodes must declare member_ids")
        if self.kind != CanvasNodeType.GROUP and self.member_ids:
            raise InvalidCanvasError("only group nodes can declare member_ids")


@dataclass(frozen=True)
class CanvasEdge:
    from_id: str
    to_id: str
    label: str | None = None

    def __post_init__(self) -> None:
        if not isinstance(self.from_id, str) or not self.from_id.strip():
            raise InvalidCanvasError("from_id cannot be empty")
        if not isinstance(self.to_id, str) or not self.to_id.strip():
            raise InvalidCanvasError("to_id cannot be empty")
        if self.label is not None and (not isinstance(self.label, str) or not self.label.strip()):
            raise InvalidCanvasError("label must be a non-empty string when provided")


@dataclass
class Canvas:
    name: CanvasName
    nodes: list[CanvasNode] = field(default_factory=list)
    edges: list[CanvasEdge] = field(default_factory=list)
    description: str | None = None

    def __post_init__(self) -> None:
        if not isinstance(self.name, CanvasName):
            raise InvalidCanvasError("name must be a CanvasName instance")
        if self.description is not None and (not isinstance(self.description, str) or not self.description.strip()):
            raise InvalidCanvasError("description must be a non-empty string when provided")
        if any(not isinstance(node, CanvasNode) for node in self.nodes):
            raise InvalidCanvasError("nodes must contain CanvasNode instances")
        if any(not isinstance(edge, CanvasEdge) for edge in self.edges):
            raise InvalidCanvasError("edges must contain CanvasEdge instances")
        self._ensure_unique_nodes()

    def _ensure_unique_nodes(self) -> None:
        node_ids = [node.id for node in self.nodes]
        if len(node_ids) != len(set(node_ids)):
            raise InvalidCanvasError("canvas cannot contain duplicate node ids")

    def add_node(self, node: CanvasNode) -> CanvasNode:
        if not isinstance(node, CanvasNode):
            raise InvalidCanvasError("node must be a CanvasNode instance")
        if node.id in {existing.id for existing in self.nodes}:
            raise InvalidCanvasError(f"duplicate node id: {node.id}")
        self.nodes.append(node)
        return node

    def add_edge(self, edge: CanvasEdge) -> CanvasEdge:
        if not isinstance(edge, CanvasEdge):
            raise InvalidCanvasError("edge must be a CanvasEdge instance")
        if edge.from_id not in {node.id for node in self.nodes}:
            raise InvalidCanvasError(f"unknown from_id: {edge.from_id}")
        if edge.to_id not in {node.id for node in self.nodes}:
            raise InvalidCanvasError(f"unknown to_id: {edge.to_id}")
        self.edges.append(edge)
        return edge

    def remove_edge(self, from_id: str, to_id: str) -> CanvasEdge:
        if not isinstance(from_id, str) or not from_id.strip():
            raise InvalidCanvasError("from_id cannot be empty")
        if not isinstance(to_id, str) or not to_id.strip():
            raise InvalidCanvasError("to_id cannot be empty")
        for index, edge in enumerate(self.edges):
            if edge.from_id == from_id and edge.to_id == to_id:
                return self.edges.pop(index)
        raise InvalidCanvasError(f"edge not found: {from_id} -> {to_id}")

    def remove_node(self, node_id: str) -> CanvasNode:
        if not isinstance(node_id, str) or not node_id.strip():
            raise InvalidCanvasError("node_id cannot be empty")
        for index, node in enumerate(self.nodes):
            if node.id == node_id:
                removed = self.nodes.pop(index)
                self.edges = [edge for edge in self.edges if edge.from_id != node_id and edge.to_id != node_id]
                return removed
        raise InvalidCanvasError(f"node not found: {node_id}")

    def get_node(self, node_id: str) -> CanvasNode:
        if not isinstance(node_id, str) or not node_id.strip():
            raise InvalidCanvasError("node_id cannot be empty")
        for node in self.nodes:
            if node.id == node_id:
                return node
        raise InvalidCanvasError(f"node not found: {node_id}")

    def get_edge(self, from_id: str, to_id: str) -> CanvasEdge:
        if not isinstance(from_id, str) or not from_id.strip():
            raise InvalidCanvasError("from_id cannot be empty")
        if not isinstance(to_id, str) or not to_id.strip():
            raise InvalidCanvasError("to_id cannot be empty")
        for edge in self.edges:
            if edge.from_id == from_id and edge.to_id == to_id:
                return edge
        raise InvalidCanvasError(f"edge not found: {from_id} -> {to_id}")

    def group_nodes(self, group_id: str, node_ids: list[str]) -> CanvasNode:
        if not isinstance(group_id, str) or not group_id.strip():
            raise InvalidCanvasError("group_id cannot be empty")
        if not isinstance(node_ids, list) or not node_ids:
            raise InvalidCanvasError("node_ids must be a non-empty list")
        unique_ids = set()
        for node_id in node_ids:
            if not isinstance(node_id, str) or not node_id.strip():
                raise InvalidCanvasError("node_ids must contain non-empty strings")
            unique_ids.add(node_id)
        if len(unique_ids) != len(node_ids):
            raise InvalidCanvasError("node_ids cannot contain duplicates")
        missing = [node_id for node_id in node_ids if node_id not in {node.id for node in self.nodes}]
        if missing:
            raise InvalidCanvasError(f"unknown node ids: {', '.join(missing)}")
        return self.add_node(
            CanvasNode(
                id=group_id.strip(),
                kind=CanvasNodeType.GROUP,
                x=0,
                y=0,
                text="",
                member_ids=list(node_ids),
            )
        )

    def layout_horizontal(self, *, start_x: int = 0, start_y: int = 0, gap: int = 320) -> None:
        if isinstance(start_x, bool) or not isinstance(start_x, int):
            raise InvalidCanvasError("start_x must be an integer")
        if isinstance(start_y, bool) or not isinstance(start_y, int):
            raise InvalidCanvasError("start_y must be an integer")
        if isinstance(gap, bool) or not isinstance(gap, int) or gap <= 0:
            raise InvalidCanvasError("gap must be a positive integer")
        for index, node in enumerate(list(self.nodes)):
            self.nodes[index] = replace(node, x=start_x + index * gap, y=start_y)

    def layout_vertical(self, *, start_x: int = 0, start_y: int = 0, gap: int = 220) -> None:
        if isinstance(start_x, bool) or not isinstance(start_x, int):
            raise InvalidCanvasError("start_x must be an integer")
        if isinstance(start_y, bool) or not isinstance(start_y, int):
            raise InvalidCanvasError("start_y must be an integer")
        if isinstance(gap, bool) or not isinstance(gap, int) or gap <= 0:
            raise InvalidCanvasError("gap must be a positive integer")
        for index, node in enumerate(list(self.nodes)):
            self.nodes[index] = replace(node, x=start_x, y=start_y + index * gap)

    def layout_grid(
        self, *, columns: int = 2, start_x: int = 0, start_y: int = 0, gap_x: int = 320, gap_y: int = 220
    ) -> None:
        if isinstance(columns, bool) or not isinstance(columns, int) or columns <= 0:
            raise InvalidCanvasError("columns must be a positive integer")
        if isinstance(start_x, bool) or not isinstance(start_x, int):
            raise InvalidCanvasError("start_x must be an integer")
        if isinstance(start_y, bool) or not isinstance(start_y, int):
            raise InvalidCanvasError("start_y must be an integer")
        if isinstance(gap_x, bool) or not isinstance(gap_x, int) or gap_x <= 0:
            raise InvalidCanvasError("gap_x must be a positive integer")
        if isinstance(gap_y, bool) or not isinstance(gap_y, int) or gap_y <= 0:
            raise InvalidCanvasError("gap_y must be a positive integer")
        for index, node in enumerate(list(self.nodes)):
            row = index // columns
            col = index % columns
            self.nodes[index] = replace(node, x=start_x + col * gap_x, y=start_y + row * gap_y)

    def notes(self) -> list[NotePath]:
        paths = [node.note_path for node in self.nodes if node.note_path is not None]
        return [path for path in paths if path is not None]
