from __future__ import annotations

import asyncio
import logging
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from contextlib import AsyncExitStack
from datetime import date
from io import StringIO
from pathlib import Path
from typing import Optional

from clarabot_nano_common.utility.path.chroot import ChrootPath
from clarabot_nano_common.utility.path.validate import validate_name
from ruamel import yaml
from tarka.utility.file import SafeFile

from clarabot_nano.inter.base import AbstractInterNanoExecutor, AbstractInterNanoRequester, AbstractDrivePathLocking
from clarabot_nano.inter.tmpfiledb import InterTemporaryFileDatabase

logger = logging.getLogger(__name__)


class BackupStatus:
    def __init__(self, packed_room_id: str):
        self.packed_room_id = packed_room_id
        self.last_start: Optional[float] = None
        self.last_folder: Optional[str] = None
        self.last_finish: Optional[float] = None
        self.current_start: Optional[float] = None
        self.current_folder: Optional[str] = None
        self.current_fail: Optional[float] = None
        self.fail_count: Optional[int] = None
        self.folder_counter: int = 1

    def should_start_new(self, interval: int, force: bool = False) -> bool:
        now = time.time()
        return self.current_folder is None and (
            self.last_folder is None or self.last_finish is None or self.last_finish + interval < now or force
        )

    def should_continue(self, backoff: int, force: bool = False) -> bool:
        now = time.time()
        return self.current_folder is not None and (
            self.current_fail is None or self.current_fail + backoff < now or force
        )

    def start_new(self, current_folder: str, folder_counter: int):
        self.current_start = time.time()
        self.current_folder = current_folder
        self.folder_counter = folder_counter
        self.fail_count = self.current_fail = None

    def reset_current(self):
        self.current_start = self.current_folder = self.fail_count = self.current_fail = None

    def finish_current(self):
        self.last_start = self.current_start
        self.last_folder = self.current_folder
        self.last_finish = time.time()
        self.reset_current()

    def fail_current(self):
        self.current_fail = time.time()
        self.fail_count = self.fail_count + 1 if isinstance(self.fail_count, int) else 1

    def load_dict(self, data: dict):
        if isinstance(data.get("last_start"), float):
            self.last_start = data["last_start"]
        if isinstance(data.get("last_folder"), str):
            try:
                for name in data["last_folder"].split("/"):
                    validate_name(name)
            except Exception:
                pass
            else:
                self.last_folder = data["last_folder"]
        if isinstance(data.get("last_finish"), float):
            self.last_finish = data["last_finish"]
        if isinstance(data.get("current_start"), float):
            self.current_start = data["current_start"]
        if isinstance(data.get("current_folder"), str):
            try:
                for name in data["current_folder"].split("/"):
                    validate_name(name)
            except Exception:
                pass
            else:
                self.current_folder = data["current_folder"]
        if isinstance(data.get("current_fail"), float):
            self.current_fail = data["current_fail"]
        if isinstance(data.get("fail_count"), int):
            self.fail_count = data["fail_count"]
        if isinstance(data.get("folder_counter"), int) and data["folder_counter"] >= 1:
            self.folder_counter = data["folder_counter"]

    def as_dict(self) -> dict:
        return {
            "room_id": self.packed_room_id,
            "last_start": self.last_start,
            "last_folder": self.last_folder,
            "last_finish": self.last_finish,
            "current_start": self.current_start,
            "current_folder": self.current_folder,
            "current_fail": self.current_fail,
            "fail_count": self.fail_count,
            "folder_counter": self.folder_counter,
        }


class InterNanoBackupExecutor(AbstractInterNanoExecutor):
    BACKUP_BACKOFF = 300
    BACKUP_FAIL_CLEAR_NUMBER = 25  # around two hours of trying
    NAME_PREFIX = "BUP-"

    def __init__(
        self,
        inr: AbstractInterNanoRequester,
        dpl: AbstractDrivePathLocking,
        owner_account_id: int,
        part_size: int,
        drive_room_id: int,
        chroot: ChrootPath,
        temp_db: InterTemporaryFileDatabase,
        backup_interval: int,
        backup_done_name: str,
        force_run: bool,
    ):
        AbstractInterNanoExecutor.__init__(self, inr, dpl, owner_account_id, part_size, drive_room_id, chroot, temp_db)
        self._backup_interval = backup_interval
        self._backup_done_name = backup_done_name
        self._force_run = force_run
        self._drive_path = f"Nano/Backup/{self.inr.remote_room_id}"
        # state
        self._incremental = False
        self._status_file: SafeFile = None
        self._base_path: Path = None

    async def _run_custom(self, pool: ThreadPoolExecutor):
        async with self._fstx_lock(self._drive_path, ensure_dir=True):
            self._base_path = self.chroot.path(self._drive_path)  # The lock has ensured the directory exists.
            self._status_file = SafeFile(self._base_path / "Status.yml")
            await self._init_check_storage()
            status = await self._read_status(pool)
            if status.should_continue(self.BACKUP_BACKOFF, force=self._force_run):
                pass
            elif status.should_start_new(self._backup_interval, force=self._force_run):
                date_str = date.today().strftime("%Y/%m/%d")
                if status.last_folder and status.last_folder.startswith(date_str):
                    i = status.folder_counter
                else:
                    i = 1
                while True:
                    current_folder = f"{date_str}_{i}" if i > 1 else date_str
                    if not (self._base_path / current_folder).exists():
                        break
                    i += 1
                status.start_new(current_folder, i)
                await self._write_status(pool, status)
            else:
                return  # Do not (yet) start/continue.
        # NOTE: We release the general lock and will only re-lock the folders and files we intend to work on.
        async with AsyncExitStack() as lock_stack:
            # Lock paths of status file and its backup.
            await lock_stack.enter_async_context(
                self._fstx_lock(self._status_file.normal.relative_to(self.chroot.root).as_posix())
            )
            await lock_stack.enter_async_context(
                self._fstx_lock(self._status_file.backup.relative_to(self.chroot.root).as_posix())
            )
            tmp_dir = self._base_path / "Temporary"
            await lock_stack.enter_async_context(self._fstx_lock(tmp_dir.relative_to(self.chroot.root).as_posix()))
            await self._loop.run_in_executor(
                pool, self._prepare_tmp_directory, tmp_dir, (status.fail_count or 0) > self.BACKUP_FAIL_CLEAR_NUMBER
            )
            new_path = self._base_path / status.current_folder
            await lock_stack.enter_async_context(self._fstx_lock(new_path.relative_to(self.chroot.root).as_posix()))
            if new_path.exists():
                if not new_path.is_dir():
                    status.reset_current()  # force reset, if root is not folder just start again clean
                    await self._write_status(pool, status)
                    raise Exception("Backup inconsistency reset (version folder)")
            else:
                new_path.mkdir(exist_ok=True, parents=True)
            if status.last_folder and status.last_folder != status.current_folder:
                old_path = self._base_path / status.last_folder
                if not old_path.is_dir():
                    old_path = None
            else:
                old_path = None
            if old_path:
                await lock_stack.enter_async_context(self._fstx_lock(old_path.relative_to(self.chroot.root).as_posix()))
            await self._backup_start(pool, status, tmp_dir, old_path, new_path)

    async def _init_check_storage(self):
        if sys.platform.startswith("linux"):
            self._incremental = await self._check_storage_reflink_support()

    async def _check_storage_reflink_support(self) -> bool:
        p0 = self._base_path / "._reflink_test_0"
        p1 = self._base_path / "._reflink_test_1"
        try:
            data = os.urandom(8)
            p0.write_bytes(data)
            if await self._reflink_copy(p0, p1):
                return p1.read_bytes() == data
        finally:
            p0.unlink(missing_ok=True)
            p1.unlink(missing_ok=True)
        return False

    async def _backup_start(
        self, pool: ThreadPoolExecutor, status: BackupStatus, tmp_dir: Path, old_path: Optional[Path], new_path: Path
    ):
        """
        At this point the FSTX-queue object has locks on the provided paths, we're going to operate directly on the
        filesystem.
        """
        # NOTE: At this point we have a workable backup setup in local. We're either starting anew or continuing, but
        # that is irrelevant for now. A continuation shall do a full scan, and skip over quickly what is already here.
        try:
            await self._sync_remote_directory_recursively(
                pool, tmp_dir, old_path, new_path, "", raise_on_listing_fail=True, granular_locking=False
            )
            # NOTE: This is for the Privy "Backup Agent" to record a successful backup process by name of this Nano.
            await self._peek_remote_item(pool, "", raise_on_fail=True, backup_done=self._backup_done_name)
            status.finish_current()
        except Exception:
            logger.exception("Failure during backup process of %s", self.inr.remote_room_id)
            status.fail_current()
        await self._write_status(pool, status, remove_backup=True)  # helps to distinguish operation finish

    async def _copy_file_old_to_new(self, pool: ThreadPoolExecutor, src: Path, trg: Path) -> bool:
        if self._incremental:
            if not (await self._reflink_copy(src, trg)):
                logger.warning("Failed to copy backup file from old snapshot %s", src)
                return False
        else:
            if not (await self._loop.run_in_executor(pool, self._move_rmdir, src, trg)):
                logger.warning("Failed to move backup file from old snapshot %s", src)
                return False
        return True

    async def _reflink_copy(self, src: Path, trg: Path) -> bool:
        proc = await asyncio.create_subprocess_exec(
            "cp",
            "--preserve=timestamps",
            "--reflink=always",
            src.as_posix(),
            trg.as_posix(),
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.STDOUT,
        )
        stdout, _ = await proc.communicate()
        return proc.returncode == 0

    def _move_rmdir(self, src: Path, trg: Path) -> bool:
        try:
            os.replace(src, trg)
        except OSError:
            return False
        parent = src.parent
        while True:  # do not leave empty directories
            try:
                parent.rmdir()
            except OSError:
                break
            else:
                parent = parent.parent
        return True

    async def _read_status(self, pool: ThreadPoolExecutor) -> BackupStatus:
        status = BackupStatus(self.inr.remote_room_id)
        try:
            tmp = await self._loop.run_in_executor(pool, self._status_file.read)
            prev_status = yaml.YAML(typ="safe", pure=True).load(StringIO(tmp.decode("utf-8")))
            if not isinstance(prev_status, dict):
                raise TypeError("backup status document is not dict")
        except Exception as e:
            if not isinstance(e, FileNotFoundError):
                logger.exception("Failed to parse backup status file")
        else:
            status.load_dict(prev_status)
        return status

    async def _write_status(self, pool: ThreadPoolExecutor, status: BackupStatus, remove_backup: bool = False):
        sio = StringIO()
        yaml.YAML(typ="safe", pure=True).dump(status.as_dict(), sio)
        await self._loop.run_in_executor(pool, self._write_status2, sio.getvalue().encode("utf-8"), remove_backup)

    def _write_status2(self, data: bytes, remove_backup=False):
        self._status_file.write(data)
        if remove_backup:
            self._status_file.backup.unlink(missing_ok=True)
