"""Builder superclass for all builders."""

from __future__ import annotations

import os
import re
import sys
from datetime import datetime, timezone
from os import path
from typing import TYPE_CHECKING, NamedTuple

import babel.dates
from babel.messages.mofile import write_mo
from babel.messages.pofile import read_po

from sphinx.errors import SphinxError
from sphinx.locale import __
from sphinx.util import logging
from sphinx.util.osutil import (
    SEP,
    _last_modified_time,
    canon_path,
    relpath,
)

if TYPE_CHECKING:
    import datetime as dt
    from collections.abc import Iterator
    from typing import Protocol, TypeAlias

    from babel.core import Locale

    from sphinx.environment import BuildEnvironment

    class DateFormatter(Protocol):
        def __call__(  # NoQA: E704
            self,
            date: dt.date | None = ...,
            format: str = ...,
            locale: str | Locale | None = ...,
        ) -> str: ...

    class TimeFormatter(Protocol):
        def __call__(  # NoQA: E704
            self,
            time: dt.time | dt.datetime | float | None = ...,
            format: str = ...,
            tzinfo: dt.tzinfo | None = ...,
            locale: str | Locale | None = ...,
        ) -> str: ...

    class DatetimeFormatter(Protocol):
        def __call__(  # NoQA: E704
            self,
            datetime: dt.date | dt.time | float | None = ...,
            format: str = ...,
            tzinfo: dt.tzinfo | None = ...,
            locale: str | Locale | None = ...,
        ) -> str: ...

    Formatter: TypeAlias = DateFormatter | TimeFormatter | DatetimeFormatter

if sys.version_info[:2] >= (3, 11):
    from datetime import UTC
else:
    UTC = timezone.utc

logger = logging.getLogger(__name__)


class LocaleFileInfoBase(NamedTuple):
    base_dir: str
    domain: str
    charset: str


class CatalogInfo(LocaleFileInfoBase):
    @property
    def po_file(self) -> str:
        return self.domain + '.po'

    @property
    def mo_file(self) -> str:
        return self.domain + '.mo'

    @property
    def po_path(self) -> str:
        return path.join(self.base_dir, self.po_file)

    @property
    def mo_path(self) -> str:
        return path.join(self.base_dir, self.mo_file)

    def is_outdated(self) -> bool:
        return (
            not path.exists(self.mo_path)
            or _last_modified_time(self.mo_path) < _last_modified_time(self.po_path)
        )  # fmt: skip

    def write_mo(self, locale: str, use_fuzzy: bool = False) -> None:
        with open(self.po_path, encoding=self.charset) as file_po:
            try:
                po = read_po(file_po, locale)
            except Exception as exc:
                logger.warning(__('reading error: %s, %s'), self.po_path, exc)
                return

        with open(self.mo_path, 'wb') as file_mo:
            try:
                write_mo(file_mo, po, use_fuzzy)
            except Exception as exc:
                logger.warning(__('writing error: %s, %s'), self.mo_path, exc)


class CatalogRepository:
    """A repository for message catalogs."""

    def __init__(
        self,
        basedir: str | os.PathLike[str],
        locale_dirs: list[str],
        language: str,
        encoding: str,
    ) -> None:
        self.basedir = basedir
        self._locale_dirs = locale_dirs
        self.language = language
        self.encoding = encoding

    @property
    def locale_dirs(self) -> Iterator[str]:
        if not self.language:
            return

        for locale_dir in self._locale_dirs:
            locale_dir = path.join(self.basedir, locale_dir)
            locale_path = path.join(locale_dir, self.language, 'LC_MESSAGES')
            if path.exists(locale_path):
                yield locale_dir
            else:
                logger.verbose(__('locale_dir %s does not exist'), locale_path)

    @property
    def pofiles(self) -> Iterator[tuple[str, str]]:
        for locale_dir in self.locale_dirs:
            basedir = path.join(locale_dir, self.language, 'LC_MESSAGES')
            for root, dirnames, filenames in os.walk(basedir):
                # skip dot-directories
                for dirname in [d for d in dirnames if d.startswith('.')]:
                    dirnames.remove(dirname)

                for filename in filenames:
                    if filename.endswith('.po'):
                        fullpath = path.join(root, filename)
                        yield basedir, relpath(fullpath, basedir)

    @property
    def catalogs(self) -> Iterator[CatalogInfo]:
        for basedir, filename in self.pofiles:
            domain = canon_path(path.splitext(filename)[0])
            yield CatalogInfo(basedir, domain, self.encoding)


def docname_to_domain(docname: str, compaction: bool | str) -> str:
    """Convert docname to domain for catalogs."""
    if isinstance(compaction, str):
        return compaction
    if compaction:
        return docname.split(SEP, 1)[0]
    else:
        return docname


# date_format mappings: ustrftime() to babel.dates.format_datetime()
date_format_mappings = {
    '%a':  'EEE',     # Weekday as locale’s abbreviated name.
    '%A':  'EEEE',    # Weekday as locale’s full name.
    '%b':  'MMM',     # Month as locale’s abbreviated name.
    '%B':  'MMMM',    # Month as locale’s full name.
    '%c':  'medium',  # Locale’s appropriate date and time representation.
    '%-d': 'd',       # Day of the month as a decimal number.
    '%d':  'dd',      # Day of the month as a zero-padded decimal number.
    '%-H': 'H',       # Hour (24-hour clock) as a decimal number [0,23].
    '%H':  'HH',      # Hour (24-hour clock) as a zero-padded decimal number [00,23].
    '%-I': 'h',       # Hour (12-hour clock) as a decimal number [1,12].
    '%I':  'hh',      # Hour (12-hour clock) as a zero-padded decimal number [01,12].
    '%-j': 'D',       # Day of the year as a decimal number.
    '%j':  'DDD',     # Day of the year as a zero-padded decimal number.
    '%-m': 'M',       # Month as a decimal number.
    '%m':  'MM',      # Month as a zero-padded decimal number.
    '%-M': 'm',       # Minute as a decimal number [0,59].
    '%M':  'mm',      # Minute as a zero-padded decimal number [00,59].
    '%p':  'a',       # Locale’s equivalent of either AM or PM.
    '%-S': 's',       # Second as a decimal number.
    '%S':  'ss',      # Second as a zero-padded decimal number.
    '%U':  'WW',      # Week number of the year (Sunday as the first day of the week)
                      # as a zero padded decimal number. All days in a new year preceding
                      # the first Sunday are considered to be in week 0.
    '%w':  'e',       # Weekday as a decimal number, where 0 is Sunday and 6 is Saturday.
    '%-W': 'W',       # Week number of the year (Monday as the first day of the week)
                      # as a decimal number. All days in a new year preceding the first
                      # Monday are considered to be in week 0.
    '%W':  'WW',      # Week number of the year (Monday as the first day of the week)
                      # as a zero-padded decimal number.
    '%x':  'medium',  # Locale’s appropriate date representation.
    '%X':  'medium',  # Locale’s appropriate time representation.
    '%y':  'YY',      # Year without century as a zero-padded decimal number.
    '%Y':  'yyyy',    # Year with century as a decimal number.
    '%Z':  'zzz',     # Time zone name (no characters if no time zone exists).
    '%z':  'ZZZ',     # UTC offset in the form ±HHMM[SS[.ffffff]]
                      # (empty string if the object is naive).
    '%%':  '%',
}  # fmt: skip

date_format_re = re.compile('(%s)' % '|'.join(date_format_mappings))


def babel_format_date(
    date: datetime,
    format: str,
    locale: str,
    formatter: Formatter = babel.dates.format_date,
) -> str:
    # Check if we have the tzinfo attribute. If not we cannot do any time
    # related formats.
    if not hasattr(date, 'tzinfo'):
        formatter = babel.dates.format_date

    try:
        return formatter(date, format, locale=locale)
    except (ValueError, babel.core.UnknownLocaleError):
        # fallback to English
        return formatter(date, format, locale='en')
    except AttributeError:
        logger.warning(
            __(
                'Invalid date format. Quote the string by single quote '
                'if you want to output it directly: %s'
            ),
            format,
        )
        return format


def format_date(
    format: str,
    *,
    date: datetime | None = None,
    language: str,
    local_time: bool = False,
) -> str:
    if date is None:
        # If time is not specified, try to use $SOURCE_DATE_EPOCH variable
        # See https://wiki.debian.org/ReproducibleBuilds/TimestampsProposal
        source_date_epoch = os.getenv('SOURCE_DATE_EPOCH')
        if source_date_epoch is not None:
            date = datetime.fromtimestamp(float(source_date_epoch), tz=UTC)
        else:
            date = datetime.now(tz=UTC)

    if local_time:
        # > If called with tz=None, the system local time zone
        # > is assumed for the target time zone.
        # https://docs.python.org/dev/library/datetime.html#datetime.datetime.astimezone
        date = date.astimezone(tz=None)

    result = []
    tokens = date_format_re.split(format)
    for token in tokens:
        if token in date_format_mappings:
            babel_format = date_format_mappings.get(token, '')

            # Check if we have to use a different babel formatter then
            # format_datetime, because we only want to format a date
            # or a time.
            function: Formatter
            if token == '%x':
                function = babel.dates.format_date
            elif token == '%X':
                function = babel.dates.format_time
            else:
                function = babel.dates.format_datetime

            result.append(
                babel_format_date(
                    date, babel_format, locale=language, formatter=function
                )
            )
        else:
            result.append(token)

    return ''.join(result)


def get_image_filename_for_language(
    filename: str | os.PathLike[str],
    env: BuildEnvironment,
) -> str:
    root, ext = path.splitext(filename)
    dirname = path.dirname(root)
    docpath = path.dirname(env.docname)
    try:
        return env.config.figure_language_filename.format(
            root=root,
            ext=ext,
            path=dirname and dirname + SEP,
            basename=path.basename(root),
            docpath=docpath and docpath + SEP,
            language=env.config.language,
        )
    except KeyError as exc:
        msg = f'Invalid figure_language_filename: {exc!r}'
        raise SphinxError(msg) from exc


def search_image_for_language(filename: str, env: BuildEnvironment) -> str:
    translated = get_image_filename_for_language(filename, env)
    _, abspath = env.relfn2path(translated)
    if path.exists(abspath):
        return translated
    else:
        return filename
