#  Copyright (c) 2023, Manfred Moitzi
#  License: MIT License
from __future__ import annotations

import pathlib
from typing import Iterable, NamedTuple, Optional, Sequence
import os
import platform
import json
import logging

from pathlib import Path
from fontTools.ttLib import TTFont, TTLibError
from .font_face import FontFace
from . import shapefile, lff

logger = logging.getLogger("ezdxf")
WINDOWS = "Windows"
LINUX = "Linux"
MACOS = "Darwin"


WIN_SYSTEM_ROOT = os.environ.get("SystemRoot", "C:/Windows")
WIN_FONT_DIRS = [
    # AutoCAD and BricsCAD do not support fonts installed in the user directory:
    "~/AppData/Local/Microsoft/Windows/Fonts",
    f"{WIN_SYSTEM_ROOT}/Fonts",
]
LINUX_FONT_DIRS = [
    "/usr/share/fonts",
    "/usr/local/share/fonts",
    "~/.fonts",
    "~/.local/share/fonts",
    "~/.local/share/texmf/fonts",
]
MACOS_FONT_DIRS = [
    "/Library/Fonts/",
    "/System/Library/Fonts/"
]
FONT_DIRECTORIES = {
    WINDOWS: WIN_FONT_DIRS,
    LINUX: LINUX_FONT_DIRS,
    MACOS: MACOS_FONT_DIRS,
}

DEFAULT_FONTS = [
    "ArialUni.ttf",  # for Windows
    "Arial Unicode.ttf",  # for macOS
    "Arial.ttf",  # for the case  "Arial Unicode" does not exist
    "DejaVuSansCondensed.ttf",  # widths of glyphs is similar to Arial
    "DejaVuSans.ttf",
    "LiberationSans-Regular.ttf",
    "OpenSans-Regular.ttf",
]
CURRENT_CACHE_VERSION = 2


class CacheEntry(NamedTuple):
    file_path: Path  # full file path e.g. "C:\Windows\Fonts\DejaVuSans.ttf"
    font_face: FontFace


GENERIC_FONT_FAMILY = {
    "serif": "DejaVu Serif",
    "sans-serif": "DejaVu Sans",
    "monospace": "DejaVu Sans Mono",
}


class FontCache:
    def __init__(self) -> None:
        # cache key is the lowercase ttf font name without parent directories
        # e.g. "arial.ttf" for "C:\Windows\Fonts\Arial.ttf"
        self._cache: dict[str, CacheEntry] = dict()

    def __contains__(self, font_name: str) -> bool:
        return self.key(font_name) in self._cache

    def __getitem__(self, item: str) -> CacheEntry:
        return self._cache[self.key(item)]

    def __setitem__(self, item: str, entry: CacheEntry) -> None:
        self._cache[self.key(item)] = entry

    def __len__(self):
        return len(self._cache)

    def clear(self) -> None:
        self._cache.clear()

    @staticmethod
    def key(font_name: str) -> str:
        return str(font_name).lower()

    def add_entry(self, font_path: Path, font_face: FontFace) -> None:
        self._cache[self.key(font_path.name)] = CacheEntry(font_path, font_face)

    def get(self, font_name: str, fallback: str) -> CacheEntry:
        try:
            return self._cache[self.key(font_name)]
        except KeyError:
            entry = self._cache.get(self.key(fallback))
            if entry is not None:
                return entry
            else:  # no fallback font available
                raise FontNotFoundError("no fonts available, not even fallback fonts")

    def find_best_match(self, font_face: FontFace) -> Optional[FontFace]:
        entry = self._cache.get(self.key(font_face.filename), None)
        if entry:
            return entry.font_face
        return self.find_best_match_ex(
            family=font_face.family,
            style=font_face.style,
            weight=font_face.weight,
            width=font_face.width,
            italic=font_face.is_italic,
        )

    def find_best_match_ex(
        self,
        family: str = "sans-serif",
        style: str = "Regular",
        weight: int = 400,
        width: int = 5,
        italic: Optional[bool] = False,
    ) -> Optional[FontFace]:
        # italic == None ... ignore italic flag
        family = GENERIC_FONT_FAMILY.get(family, family)
        entries = filter_family(family, self._cache.values())
        if len(entries) == 0:
            return None
        elif len(entries) == 1:
            return entries[0].font_face
        entries_ = filter_style(style, entries)
        if len(entries_) == 1:
            return entries_[0].font_face
        elif len(entries_):
            entries = entries_
        # best match by weight, italic, width
        # Note: the width property is used to prioritize shapefile types:
        # 1st .shx; 2nd: .shp; 3rd: .lff
        result = sorted(
            entries,
            key=lambda e: (
                abs(e.font_face.weight - weight),
                e.font_face.is_italic is not italic,
                abs(e.font_face.width - width),
            ),
        )
        return result[0].font_face

    def loads(self, s: str) -> None:
        cache: dict[str, CacheEntry] = dict()
        try:
            content = json.loads(s)
        except json.JSONDecodeError:
            raise IOError("invalid JSON file format")
        try:
            version = content["version"]
            content = content["font-faces"]
        except KeyError:
            raise IOError("invalid cache file format")
        if version == CURRENT_CACHE_VERSION:
            for entry in content:
                try:
                    file_path, family, style, weight, width = entry
                except ValueError:
                    raise IOError("invalid cache file format")
                path = Path(file_path)  # full path, e.g. "C:\Windows\Fonts\Arial.ttf"
                font_face = FontFace(
                    filename=path.name,  # file name without parent dirs, e.g. "Arial.ttf"
                    family=family,  # Arial
                    style=style,  # Regular
                    weight=weight,  # 400 (Normal)
                    width=width,  # 5 (Normal)
                )
                cache[self.key(path.name)] = CacheEntry(path, font_face)
        else:
            raise IOError("invalid cache file version")
        self._cache = cache

    def dumps(self) -> str:
        faces = [
            (
                str(entry.file_path),
                entry.font_face.family,
                entry.font_face.style,
                entry.font_face.weight,
                entry.font_face.width,
            )
            for entry in self._cache.values()
        ]
        data = {"version": CURRENT_CACHE_VERSION, "font-faces": faces}
        return json.dumps(data, indent=2)

    def print_available_fonts(self, verbose=False) -> None:
        for entry in self._cache.values():
            print(f"{entry.file_path}")
            if not verbose:
                continue
            font_type = entry.file_path.suffix.lower()
            ff = entry.font_face
            if font_type in (".shx", ".shp"):
                print(f"  Shape font file: '{ff.filename}'")
            elif font_type == ".lff":
                print(f"  LibreCAD font file: '{ff.filename}'")
            else:
                print(f"  TrueType/OpenType font file: '{ff.filename}'")
                print(f"  family: {ff.family}")
                print(f"  style: {ff.style}")
                print(f"  weight: {ff.weight}, {ff.weight_str}")
                print(f"  width: {ff.width}, {ff.width_str}")
        print(f"\nfound {len(self._cache)} fonts")


def filter_family(family: str, entries: Iterable[CacheEntry]) -> list[CacheEntry]:
    key = str(family).lower()
    return [e for e in entries if e.font_face.family.lower().startswith(key)]


def filter_style(style: str, entries: Iterable[CacheEntry]) -> list[CacheEntry]:
    key = str(style).lower()
    return [e for e in entries if key in e.font_face.style.lower()]


# TrueType and OpenType fonts:
# Note: CAD applications like AutoCAD/BricsCAD do not support OpenType fonts!
SUPPORTED_TTF_TYPES = {".ttf", ".ttc", ".otf"}
# Basic stroke-fonts included in CAD applications:
SUPPORTED_SHAPE_FILES = {".shx", ".shp", ".lff"}
NO_FONT_FACE = FontFace()
FALLBACK_SHAPE_FILES = ["txt.shx", "txt.shp", "iso.shx", "iso.shp"]
FALLBACK_LFF = ["standard.lff", "iso.lff", "simplex.lff"]


class FontNotFoundError(Exception):
    pass


class UnsupportedFont(Exception):
    pass


class FontManager:
    def __init__(self) -> None:
        self.platform = platform.system()
        self._font_cache: FontCache = FontCache()
        self._match_cache: dict[int, Optional[FontFace]] = dict()
        self._loaded_ttf_fonts: dict[str, TTFont] = dict()
        self._loaded_shape_file_glyph_caches: dict[str, shapefile.GlyphCache] = dict()
        self._loaded_lff_glyph_caches: dict[str, lff.GlyphCache] = dict()
        self._fallback_font_name = ""
        self._fallback_shape_file = ""
        self._fallback_lff = ""

    def print_available_fonts(self, verbose=False) -> None:
        self._font_cache.print_available_fonts(verbose=verbose)

    def has_font(self, font_name: str) -> bool:
        return font_name in self._font_cache

    def clear(self) -> None:
        self._font_cache = FontCache()
        self._loaded_ttf_fonts.clear()
        self._fallback_font_name = ""

    def fallback_font_name(self) -> str:
        fallback_name = self._fallback_font_name
        if fallback_name:
            return fallback_name
        fallback_name = DEFAULT_FONTS[0]
        for name in DEFAULT_FONTS:
            try:
                cache_entry = self._font_cache.get(name, fallback_name)
                fallback_name = cache_entry.file_path.name
                break
            except FontNotFoundError:
                pass
        self._fallback_font_name = fallback_name
        return fallback_name

    def fallback_shapefile(self) -> str:
        fallback_shape_file = self._fallback_shape_file
        if fallback_shape_file:
            return fallback_shape_file

        for name in FALLBACK_SHAPE_FILES:
            if name in self._font_cache:
                self._fallback_shape_file = name
                return name
        return ""

    def fallback_lff(self) -> str:
        fallback_lff = self._fallback_lff
        if fallback_lff:
            return fallback_lff

        for name in FALLBACK_SHAPE_FILES:
            if name in self._font_cache:
                self._fallback_shape_file = name
                return name
        return ""

    def get_ttf_font(self, font_name: str, font_number: int = 0) -> TTFont:
        try:
            return self._loaded_ttf_fonts[font_name]
        except KeyError:
            pass
        fallback_name = self.fallback_font_name()
        try:
            font = TTFont(
                self._font_cache.get(font_name, fallback_name).file_path,
                fontNumber=font_number,
            )
        except IOError as e:
            raise FontNotFoundError(str(e))
        except TTLibError as e:
            raise FontNotFoundError(str(e))
        self._loaded_ttf_fonts[font_name] = font
        return font

    def ttf_font_from_font_face(self, font_face: FontFace) -> TTFont:
        return self.get_ttf_font(Path(font_face.filename).name)

    def get_shapefile_glyph_cache(self, font_name: str) -> shapefile.GlyphCache:
        try:
            return self._loaded_shape_file_glyph_caches[font_name]
        except KeyError:
            pass
        fallback_name = self.fallback_shapefile()
        try:
            file_path = self._font_cache.get(font_name, fallback_name).file_path
        except KeyError:
            raise FontNotFoundError(f"shape font '{font_name}' not found")
        try:
            file = shapefile.readfile(str(file_path))
        except IOError:
            raise FontNotFoundError(f"shape file '{file_path}' not found")
        except shapefile.UnsupportedShapeFile as e:
            raise UnsupportedFont(f"unsupported font'{file_path}': {str(e)}")
        glyph_cache = shapefile.GlyphCache(file)
        self._loaded_shape_file_glyph_caches[font_name] = glyph_cache
        return glyph_cache

    def get_lff_glyph_cache(self, font_name: str) -> lff.GlyphCache:
        try:
            return self._loaded_lff_glyph_caches[font_name]
        except KeyError:
            pass
        fallback_name = self.fallback_lff()
        try:
            file_path = self._font_cache.get(font_name, fallback_name).file_path
        except KeyError:
            raise FontNotFoundError(f"LibreCAD font '{font_name}' not found")
        try:
            s = pathlib.Path(file_path).read_text(encoding="utf8")
            font = lff.loads(s)
        except IOError:
            raise FontNotFoundError(f"LibreCAD font file '{file_path}' not found")
        glyph_cache = lff.GlyphCache(font)
        self._loaded_lff_glyph_caches[font_name] = glyph_cache
        return glyph_cache

    def get_font_face(self, font_name: str) -> FontFace:
        cache_entry = self._font_cache.get(font_name, self.fallback_font_name())
        return cache_entry.font_face

    def find_best_match(
        self,
        family: str = "sans-serif",
        style: str = "Regular",
        weight=400,
        width=5,
        italic: Optional[bool] = False,
    ) -> Optional[FontFace]:
        key = hash((family, style, weight, width, italic))
        try:
            return self._match_cache[key]
        except KeyError:
            pass
        font_face = self._font_cache.find_best_match_ex(
            family, style, weight, width, italic
        )
        self._match_cache[key] = font_face
        return font_face

    def find_font_name(self, font_face: FontFace) -> str:
        """Returns the font file name of the font without parent directories
        e.g. "LiberationSans-Regular.ttf".
        """
        font_face = self._font_cache.find_best_match(font_face)  # type: ignore
        if font_face is None:
            font_face = self.get_font_face(self.fallback_font_name())
            return font_face.filename
        else:
            return font_face.filename

    def build(self, folders: Optional[Sequence[str]] = None, support_dirs=True) -> None:
        """Adds all supported font types located in the given `folders` to the font
        manager. If no directories are specified, the known font folders for Windows,
        Linux and macOS are searched by default, except `support_dirs` is ``False``.
        Searches recursively all subdirectories.

        The folders stored in the config SUPPORT_DIRS option are scanned recursively for
        .shx, .shp and .lff fonts, the basic stroke fonts included in CAD applications.

        """
        from ezdxf._options import options

        if folders:
            dirs = list(folders)
        else:
            dirs = FONT_DIRECTORIES.get(self.platform, LINUX_FONT_DIRS)
        if support_dirs:
            dirs = dirs + list(options.support_dirs)
        self.scan_all(dirs)

    def add_synonyms(self, synonyms: dict[str, str], reverse=True) -> None:
        font_cache = self._font_cache
        for font_name, synonym in synonyms.items():
            if not font_name in font_cache:
                continue
            if synonym in font_cache:
                continue
            cache_entry = font_cache[font_name]
            font_cache[synonym] = cache_entry
        if reverse:
            self.add_synonyms({v: k for k, v in synonyms.items()}, reverse=False)

    def scan_all(self, folders: Iterable[str]) -> None:
        for folder in folders:
            folder = folder.strip("'\"")  # strip quotes
            if not folder:
                continue
            try:
                self.scan_folder(Path(folder).expanduser())
            except PermissionError as e:
                print(str(e))
                continue

    def scan_folder(self, folder: Path):
        if not folder.exists():
            return
        for file in folder.iterdir():
            if file.is_dir():
                self.scan_folder(file)
                continue
            ext = file.suffix.lower()
            if ext in SUPPORTED_TTF_TYPES:
                try:
                    font_face = get_ttf_font_face(file)
                except Exception as e:
                    logger.warning(f"cannot open font '{file}': {str(e)}")
                else:
                    self._font_cache.add_entry(file, font_face)
            elif ext in SUPPORTED_SHAPE_FILES:
                font_face = get_shape_file_font_face(file)
                self._font_cache.add_entry(file, font_face)

    def dumps(self) -> str:
        return self._font_cache.dumps()

    def loads(self, s: str) -> None:
        self._font_cache.loads(s)


def normalize_style(style: str) -> str:
    if style in {"Book"}:
        style = "Regular"
    return style


def get_ttf_font_face(font_path: Path) -> FontFace:
    """The caller should catch ALL exception (see scan_folder function above) - strange
    things can happen when reading TTF files.
    """
    ttf = TTFont(font_path, fontNumber=0)
    names = ttf["name"].names
    family = ""
    style = ""
    for record in names:
        if record.nameID == 1:
            family = record.string.decode(record.getEncoding())
        elif record.nameID == 2:
            style = record.string.decode(record.getEncoding())
        if family and style:
            break

    try:
        os2_table = ttf["OS/2"]
    except Exception:  # e.g. ComickBook_Simple.ttf has an invalid "OS/2" table
        logger.info(f"cannot load OS/2 table of font '{font_path.name}'")
        weight = 400
        width = 5
    else:
        weight = os2_table.usWeightClass
        width = os2_table.usWidthClass
    return FontFace(
        filename=font_path.name,
        family=family,
        style=normalize_style(style),
        width=width,
        weight=weight,
    )


def get_shape_file_font_face(font_path: Path) -> FontFace:
    ext = font_path.suffix.lower()
    # Note: the width property is not defined in shapefiles and is used to
    # prioritize the shapefile types for find_best_match():
    # 1st .shx; 2nd: .shp; 3rd: .lff

    width = 5
    if ext == ".shp":
        width = 6
    if ext == ".lff":
        width = 7

    return FontFace(
        filename=font_path.name,  # "txt.shx", "simplex.shx", ...
        family=font_path.stem.lower(),  # "txt", "simplex", ...
        style=font_path.suffix.lower(),  # ".shx", ".shp" or ".lff"
        width=width,
        weight=400,
    )
