import datetime
import glob
import hashlib
import logging
import os
import re
import shutil
import urllib.parse
from pathlib import Path
from typing import Any, Iterable, List, Optional

from avtdl.core.download import download_file
from avtdl.core.formatters import sanitize_filename
from avtdl.core.interfaces import Record
from avtdl.core.request import HttpClient


def find_file(path: Path) -> List[Path]:
    """given path to a file without extension, return list of existing files with exact path and name and any extension"""
    if not path.parent.exists():
        return []
    try:
        return [p for p in path.parent.iterdir() if p.stem == path.name]
    except OSError:
        return []


def find_with_suffix(path: Path, suffix_template: str) -> List[Path]:
    """given path to possible existing file name without extension and a suffix template,
    find and return a sorted list of all existing files with given name and suffix.
    The suffix_template parameter must contain "{i}" exactly once"""
    if suffix_template.count("{i}") != 1:
        raise ValueError("The suffix_template must contain '{i}' exactly once.")
    if not path.parent.exists():
        return []
    base_name = strip_rename_suffix(path.name, suffix_template)
    pattern = f'{glob.escape(base_name)}*'
    matches = []
    matches_with_suffix = []
    for p in path.parent.glob(pattern):
        if p.stem == base_name:
            matches.append(p)
        elif strip_rename_suffix(p.stem, suffix_template) == base_name:
            matches_with_suffix.append(p)

    matches_with_suffix.sort()
    matches.extend(matches_with_suffix)
    return matches


def strip_rename_suffix(name: str, suffix_template: str) -> str:
    """given file name without extension, remove suffix matching suffix_template"""
    re_suffix = re.escape(suffix_template).replace(re.escape('{i}'), r'\d+') + '$'
    new_name = re.sub(re_suffix, '', name)
    return new_name


def find_free_suffix(path: Path, suffix_template: str) -> Path:
    """given path to possible existing file name without extension, find a free name
    generated by appending suffix_template to original filename, such that no file exists
    with that name and any extension. The suffix_template parameter must contain "{i}" exactly once"""
    if suffix_template.count('{i}') != 1:
        raise ValueError("The suffix_template must contain '{i}' exactly once.")

    base_name = path.name
    new_path = Path(path)

    i = 0
    while find_file(new_path):
        i += 1
        suffix = suffix_template.format(i=i)
        new_path = path.with_name(f'{base_name}{suffix}')
    return new_path


def has_expired(file: Path, ttl: Optional[float]) -> bool:
    """check if file was created earlier than ttl hours ago"""
    if ttl is None:
        return False
    if ttl == 0:
        return True
    if not file.exists():
        return True
    try:
        stat = file.stat()
    except OSError:
        return True
    modified = datetime.datetime.fromtimestamp(stat.st_ctime)
    now = datetime.datetime.now()
    hours_passed = (now - modified).total_seconds() / 3600

    return hours_passed > ttl


class FileCache:
    """
    Provide interface to store and retrieve url content locally

    Uses name of the record field containing the url, url host
    and md5(url) to generate unique path for an url.

    cache_directory: path to cache directory. Multiple instances
    pointing to the same directory should be able to coexist
    (at least read-only and write-only ones)

    partial_file_suffix: new file initially has this extension and is then
    renamed once actual extension becomes known after download

    This plugin is under active development. The way it works might change,
    options might be renamed or removed.
    """

    RENAME_SUFFIX = ' [{i}]'

    def __init__(self, cache_directory: Path, partial_file_suffix: str):
        self.cache_directory = cache_directory.resolve()
        self.partial_file_suffix = partial_file_suffix
        self.logger = logging.getLogger('cache')

    @staticmethod
    def _field_name_by_value(record: Record, value: Any, default='default') -> str:
        """return name of record's field containing value, or default if it wasn't found"""
        fields = type(record).model_fields.keys()
        for field in fields:
            field_value = getattr(record, field, None)
            if field_value == value:
                return field
            if isinstance(field_value, Iterable) and value in field_value:
                return field
        return default

    def filename_for(self, record: Record, url: str) -> Path:
        """generate path and name (without extension) for given url"""
        subdirectory = Path(urllib.parse.urlparse(url).netloc) / self._field_name_by_value(record, url)
        url_hash = hashlib.md5(url.encode()).hexdigest().upper()
        name = sanitize_filename(f'{url_hash}', collapse=True)
        name = Path(name)
        filename = self.cache_directory / subdirectory / name
        return filename

    def retrieve(self, record: Record, url: str) -> Optional[Path]:
        path = self.filename_for(record, url)
        file = self._find_file(path, url)
        return file

    async def store(self, logger: logging.Logger, client: HttpClient,
                    record: Record, url: str,
                    replace_after: Optional[float] = None, external_path: Optional[Path] = None) -> Optional[Path]:
        """download and store or find existing local copy of the url, return path to the local file.
        Use existing file "unless replace_after" hours has passed since it was created, otherwise
        pick a new name and download anyway. If external_path is provided and is a file, it is
        copied to cache directory instead of downloading"""
        store_path = self.filename_for(record, url)
        file = self._find_file(store_path, url)
        if file and not has_expired(file, replace_after):
            self.logger.debug(f'reusing stored file "{file}" for "{url}"')
            return file
        store_path = find_free_suffix(store_path, self.RENAME_SUFFIX)
        try:
            store_path.parent.mkdir(parents=True, exist_ok=True)
        except OSError as e:
            logger.warning(f'failed to create directory hierarchy "{store_path.parent}", skipping record {record!r}')
            return None
        if external_path and external_path.exists() and external_path.is_file():
            logger.debug(f'reusing external file "{external_path}" for "{url}"')
            try:
                shutil.copy2(external_path, store_path)
                return store_path
            except OSError as e:
                logger.warning(f'failed to copy external file "{external_path}" to "{store_path}", downloading')
        return await self._download_file(logger, client, url, store_path)

    async def _download_file(self, logger: logging.Logger, client: HttpClient, url: str, path: Path) -> Optional[Path]:
        """download file from given url, store to path, return path if successful"""
        temp_path = path.with_name(path.name + self.partial_file_suffix)
        info = await download_file(url, temp_path, client.session, logger=logger)
        if info is None:
            return None
        final_path = temp_path.with_suffix(info.extension)
        try:
            logger.debug(f'moving "{temp_path}" to "{final_path}"')
            os.replace(temp_path, final_path)
            return final_path
        except Exception as e:
            message = f'failed to move file "{temp_path}" to desired location "{final_path}": {e}'
            logger.warning(message)
            return None

    def _find_file(self, path: Path, url: str) -> Optional[Path]:
        """given path to a file without extension, find and return existing file"""
        files = find_with_suffix(path, self.RENAME_SUFFIX)
        if len(files) == 1:
            file = files[0]
            self.logger.debug(f'url "{url} is stored at "{file}"')
            return file
        elif len(files) > 1:
            for file in reversed(files):
                if url.endswith(file.suffix):
                    self.logger.debug(f'url "{url} is stored at "{file}" (picked best match out of {len(files)})')
                    return file
            self.logger.warning(f'found multiple files suitable for url "{url}": {files}')
            return files[0]
        else:
            return None
