from __future__ import annotations

import asyncio
import datetime as _dt
import re
import threading
from pathlib import Path
from typing import Any, Callable, Optional

from chronml import Chron

from .model import CompactRecord, Message

DEFAULT_META_DIRNAME = ".oturn"
SESSIONS_DIRNAME = "sessions"
_SESSION_MARKER_PREFIX = "md_as_config:ctx:"


def _session_chron() -> Chron:
    return Chron(marker_prefix=_SESSION_MARKER_PREFIX)


def _normalize_meta_dirname(meta_dirname: str) -> str:
    return str(meta_dirname or DEFAULT_META_DIRNAME)


def _resolve_global_sessions_root(
    *,
    global_sessions_root: Path | None,
    meta_dirname: str,
) -> Path:
    if global_sessions_root is not None:
        return global_sessions_root
    return Path.home() / _normalize_meta_dirname(meta_dirname) / "global_sessions"


class SessionStore:
    def __init__(
        self,
        file_backend: Path,
        session_id: str | None = None,
        work_dir: Path | None = None,
        *,
        meta_dirname: str = DEFAULT_META_DIRNAME,
        global_sessions_root: Path | None = None,
    ):
        self.file_backend = file_backend
        self.session_id = session_id
        self.work_dir = work_dir
        self.meta_dirname = _normalize_meta_dirname(meta_dirname)
        self.global_sessions_root = global_sessions_root
        self._persist_ready: bool = self.file_backend.exists()
        self._persist_lock = threading.Lock()

    def restore(self) -> list[Message | CompactRecord]:
        if not self.file_backend.exists() or self.file_backend.stat().st_size == 0:
            return []
        chron = _session_chron()
        try:
            records = chron.load(self.file_backend)
            return list(records or [])
        except ValueError as exc:
            # Historical bug: multiline assistant content could be persisted as
            # inline value-begin/value-end markers across multiple lines.
            if "value-begin without value-end in same line" not in str(exc):
                raise
            original_text = self.file_backend.read_text(encoding="utf-8")
            repaired_text = self._repair_broken_inline_values(original_text)
            if not repaired_text or repaired_text == original_text:
                raise
            records = chron.loads(repaired_text)
            self._backup_and_rewrite_corrupted_session(original_text, repaired_text)
            return list(records or [])

    @staticmethod
    def _repair_broken_inline_values(text: str) -> str | None:
        lines = text.splitlines()
        out: list[str] = []
        in_inline_block = False
        changed = False
        end_pat = re.compile(r"<!--\s*md_as_config:ctx:value-end\s*-->")

        for line in lines:
            if not in_inline_block:
                if (
                    "md_as_config:ctx:value-begin" in line
                    and "md_as_config:ctx:value-end" not in line
                    and "md_as_config:ctx:multi-line-value-begin" not in line
                ):
                    out.append(line.replace(":value-begin", ":multi-line-value-begin", 1))
                    in_inline_block = True
                    changed = True
                else:
                    out.append(line)
                continue

            m = end_pat.search(line)
            if m is None:
                out.append(line)
                continue

            content_prefix = line[: m.start()].rstrip()
            if content_prefix:
                out.append(content_prefix)
            out.append(m.group(0).replace(":value-end", ":multi-line-value-end", 1))
            tail = line[m.end() :].strip()
            if tail:
                out.append(tail)
            in_inline_block = False
            changed = True

        if in_inline_block or not changed:
            return None
        repaired = "\n".join(out)
        if text.endswith("\n"):
            repaired += "\n"
        return repaired

    def _backup_and_rewrite_corrupted_session(self, original_text: str, repaired_text: str) -> None:
        ts = _dt.datetime.now().strftime("%Y%m%d%H%M%S")
        backup = self.file_backend.with_suffix(self.file_backend.suffix + f".broken-{ts}")
        try:
            backup.write_text(original_text, encoding="utf-8")
        except Exception:
            pass
        self.file_backend.write_text(repaired_text, encoding="utf-8")

    def append_item(self, item: Any, template_func: Optional[Callable[[Any], str]] = None) -> None:
        self.ensure_persisted()
        item_md = _session_chron().dumps([item], template="", template_func=template_func)
        with open(self.file_backend, "a", encoding="utf-8") as f:
            f.write(item_md)
            f.write("\n\n")
            f.flush()

    def dump_history(
        self,
        history: list[Any],
        target_path: Path,
        template_func: Optional[Callable[[Any], str]] = None,
        template: str = "",
    ) -> None:
        target_path.parent.mkdir(parents=True, exist_ok=True)
        _session_chron().dump(history, target_path, template=template, template_func=template_func)

    def export_md_as_config(
        self,
        history: list[Any],
        md_path: Path,
        template_func: Optional[Callable[[Any], str]] = None,
        template: str = "",
    ) -> None:
        self.dump_history(history, md_path, template_func=template_func, template=template)

    def import_md_as_config(self, md_path: Path) -> list[Any]:
        if not md_path.exists() or md_path.stat().st_size == 0:
            return []
        records = _session_chron().load(md_path)
        if not records:
            return []
        self.file_backend.parent.mkdir(parents=True, exist_ok=True)
        self.file_backend.write_text(md_path.read_text(encoding="utf-8"), encoding="utf-8")
        return list(records)

    @staticmethod
    def sessions_dir(work_dir: Path, *, meta_dirname: str = DEFAULT_META_DIRNAME) -> Path:
        return work_dir / _normalize_meta_dirname(meta_dirname) / SESSIONS_DIRNAME

    @staticmethod
    def global_sessions_dir(
        _work_dir: Path,
        *,
        global_sessions_root: Path | None = None,
        meta_dirname: str = DEFAULT_META_DIRNAME,
    ) -> Path:
        date_str = _dt.datetime.now().strftime("%Y%m%d")
        root = _resolve_global_sessions_root(
            global_sessions_root=global_sessions_root,
            meta_dirname=meta_dirname,
        )
        return root / date_str

    @staticmethod
    def ensure_sessions_dir(work_dir: Path, *, meta_dirname: str = DEFAULT_META_DIRNAME) -> Path:
        p = SessionStore.sessions_dir(work_dir, meta_dirname=meta_dirname)
        p.mkdir(parents=True, exist_ok=True)
        return p

    @staticmethod
    def ensure_global_sessions_dir(
        work_dir: Path,
        *,
        global_sessions_root: Path | None = None,
        meta_dirname: str = DEFAULT_META_DIRNAME,
    ) -> Path:
        p = SessionStore.global_sessions_dir(
            work_dir,
            global_sessions_root=global_sessions_root,
            meta_dirname=meta_dirname,
        )
        p.mkdir(parents=True, exist_ok=True)
        return p

    @staticmethod
    def history_candidates(sessions_dir: Path, session_id: str) -> list[Path]:
        return [sessions_dir / f"{session_id}.md"]

    @classmethod
    def session_exists(cls, work_dir: Path, session_id: str, *, meta_dirname: str = DEFAULT_META_DIRNAME) -> bool:
        sessions_dir = cls.sessions_dir(work_dir, meta_dirname=meta_dirname)
        for candidate in cls.history_candidates(sessions_dir, session_id):
            if candidate.exists():
                return True
        return False

    @classmethod
    def create_for_workdir(
        cls,
        work_dir: Path,
        session_id: str | None = None,
        *,
        defer_persist: bool = False,
        meta_dirname: str = DEFAULT_META_DIRNAME,
        global_sessions_root: Path | None = None,
    ) -> "SessionStore":
        work_dir = work_dir.absolute()
        sessions_dir = (
            cls.global_sessions_dir(
                work_dir,
                global_sessions_root=global_sessions_root,
                meta_dirname=meta_dirname,
            )
            if defer_persist
            else cls.ensure_global_sessions_dir(
                work_dir,
                global_sessions_root=global_sessions_root,
                meta_dirname=meta_dirname,
            )
        )
        sid = session_id
        if not sid or sid == "[AUTO]":
            now = _dt.datetime.now()
            sid = now.strftime("%Y%m%d%H%M%S%f")[:17]
            while cls.session_exists(work_dir, sid, meta_dirname=meta_dirname):
                sid = f"{sid}-1"
        history_file = sessions_dir / f"{sid}.md"
        store = cls(
            history_file,
            session_id=sid,
            work_dir=work_dir,
            meta_dirname=meta_dirname,
            global_sessions_root=global_sessions_root,
        )
        if not defer_persist:
            store.ensure_persisted()
        return store

    def ensure_persisted(self) -> None:
        if self._persist_ready:
            return
        with self._persist_lock:
            if self._persist_ready:
                return
            work_dir = self.work_dir
            sid = self.session_id or self.file_backend.stem
            if work_dir is not None and sid:
                self.file_backend.parent.mkdir(parents=True, exist_ok=True)
                self.file_backend.touch(exist_ok=True)
                try:
                    local_dir = self.ensure_sessions_dir(work_dir, meta_dirname=self.meta_dirname)
                    local_link = local_dir / f"{sid}.md"
                    if not local_link.exists() and not local_link.is_symlink():
                        local_link.symlink_to(self.file_backend.absolute())
                except Exception:
                    pass
            else:
                self.file_backend.parent.mkdir(parents=True, exist_ok=True)
                self.file_backend.touch(exist_ok=True)
            self._persist_ready = True

    async def aensure_persisted(self) -> None:
        await asyncio.to_thread(self.ensure_persisted)

    @classmethod
    def continue_in_workdir(cls, work_dir: Path, *, meta_dirname: str = DEFAULT_META_DIRNAME) -> tuple[str, Path] | None:
        sessions, _ = cls.list_sessions(work_dir, meta_dirname=meta_dirname)
        if not sessions:
            return None
        sid, path, _ = sessions[0]
        return sid, path

    @classmethod
    def resolve_session_path(cls, work_dir: Path, session_id: str, *, meta_dirname: str = DEFAULT_META_DIRNAME) -> Path | None:
        sessions_dir = cls.sessions_dir(work_dir, meta_dirname=meta_dirname)
        for candidate in cls.history_candidates(sessions_dir, session_id):
            if candidate.exists():
                return candidate.resolve() if candidate.is_symlink() else candidate
        return None

    @classmethod
    def list_sessions(
        cls,
        work_dir: Path,
        *,
        meta_dirname: str = DEFAULT_META_DIRNAME,
    ) -> tuple[list[tuple[str, Path, float]], str | None]:
        work_dir = work_dir.absolute()
        sessions_dir = cls.sessions_dir(work_dir, meta_dirname=meta_dirname)
        entries: dict[str, tuple[Path, float]] = {}
        if sessions_dir.exists():
            for p in sessions_dir.glob("*.md"):
                sid = p.stem
                try:
                    if p.is_symlink():
                        target = p.resolve()
                        ts = target.stat().st_mtime if target.exists() else p.stat().st_mtime
                    else:
                        ts = p.stat().st_mtime
                except Exception:
                    ts = 0.0
                entries[sid] = (p, ts)
        items = [(sid, path, ts) for sid, (path, ts) in entries.items()]
        items.sort(key=lambda x: x[2], reverse=True)
        return items, None

    @classmethod
    def list_global_sessions(
        cls,
        _work_dir: Path,
        *,
        global_sessions_root: Path | None = None,
        meta_dirname: str = DEFAULT_META_DIRNAME,
    ) -> tuple[list[tuple[str, Path, float]], str | None]:
        root = _resolve_global_sessions_root(
            global_sessions_root=global_sessions_root,
            meta_dirname=meta_dirname,
        )
        entries: dict[str, tuple[Path, float]] = {}
        if root.exists():
            for date_dir in sorted(root.iterdir(), reverse=True):
                if not date_dir.is_dir():
                    continue
                for p in date_dir.glob("*.md"):
                    sid = p.stem
                    if sid in entries:
                        continue
                    try:
                        ts = p.stat().st_mtime
                    except Exception:
                        ts = 0.0
                    entries[sid] = (p, ts)
        items = [(sid, path, ts) for sid, (path, ts) in entries.items()]
        items.sort(key=lambda x: x[2], reverse=True)
        return items, None
