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

from __future__ import annotations

import os
import os.path
import re
from datetime import datetime
from typing import TYPE_CHECKING

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._pathlib import _StrPath
from sphinx.util.osutil import SEP, _last_modified_time

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__(
            self,
            date: dt.date | None = ...,
            format: str = ...,
            locale: str | Locale | None = ...,
        ) -> str: ...

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

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

    Formatter: TypeAlias = DateFormatter | TimeFormatter | DatetimeFormatter

from datetime import UTC

logger = logging.getLogger(__name__)


class CatalogInfo:
    __slots__ = 'base_dir', 'domain', 'charset'

    def __init__(
        self, base_dir: str | os.PathLike[str], domain: str, charset: str
    ) -> None:
        self.base_dir = _StrPath(base_dir)
        self.domain = domain
        self.charset = charset

    @property
    def po_file(self) -> str:
        return f'{self.domain}.po'

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

    @property
    def po_path(self) -> _StrPath:
        return self.base_dir / self.po_file

    @property
    def mo_path(self) -> _StrPath:
        return self.base_dir / self.mo_file

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

    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,
                    type='i18n',
                    subtype='not_readable',
                )
                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,
                    type='i18n',
                    subtype='not_writeable',
                )


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 = _StrPath(basedir)
        self._locale_dirs = locale_dirs
        self.language = language
        self.encoding = encoding

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

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

    @property
    def pofiles(self) -> Iterator[tuple[_StrPath, _StrPath]]:
        for locale_dir in self.locale_dirs:
            locale_path = locale_dir / self.language / 'LC_MESSAGES'
            for abs_path in locale_path.rglob('*.po'):
                rel_path = abs_path.relative_to(locale_path)
                # skip dot-directories
                if any(part.startswith('.') for part in rel_path.parts[:-1]):
                    continue
                yield locale_path, rel_path

    @property
    def catalogs(self) -> Iterator[CatalogInfo]:
        for basedir, filename in self.pofiles:
            domain = filename.with_suffix('').as_posix()
            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
        logger.warning(
            __('Invalid Babel locale: %r.'),
            locale,
            type='i18n',
            subtype='babel',
        )
        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,
            type='i18n',
            subtype='babel',
        )
        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)
            # If SOURCE_DATE_EPOCH is set, users likely want a reproducible result,
            # so enforce GMT/UTC for consistency.
            local_time = False
        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 = os.path.splitext(filename)
    dirname = os.path.dirname(root)
    docpath = os.path.dirname(env.docname)
    try:
        return env.config.figure_language_filename.format(
            root=root,
            ext=ext,
            path=dirname and dirname + SEP,
            basename=os.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 os.path.exists(abspath):
        return translated
    else:
        return filename
