#!/usr/local/bin/python3.13


__license__   = 'GPL v3'
__copyright__ = '2009, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'

import os
import re
import traceback

from calibre import prints, sanitize_file_name, strftime
from calibre.constants import DEBUG, iswindows, preferred_encoding
from calibre.db.errors import NoSuchFormat
from calibre.db.lazy import FormatsList
from calibre.ebooks.metadata import fmt_sidx, title_sort
from calibre.utils.config import Config, StringConfig, tweaks
from calibre.utils.date import as_local_time, is_date_undefined
from calibre.utils.filenames import ascii_filename, make_long_path_useable, shorten_components_to
from calibre.utils.formatter import TemplateFormatter
from calibre.utils.formatter_functions import load_user_template_functions
from calibre.utils.localization import _

plugboard_any_device_value = 'any device'
plugboard_any_format_value = 'any format'
plugboard_save_to_disk_value = 'save_to_disk'


DEFAULT_TEMPLATE = '{author_sort}/{title}/{title} - {authors}'
DEFAULT_SEND_TEMPLATE = '{author_sort}/{title} - {authors}'

FORMAT_ARG_DESCS = {
        'title': _('The title'),
        'authors': _('The authors'),
        'author_sort': _('The author sort string. To use only the first letter '
            'of the name use {author_sort[0]}'),
        'tags': _('The tags'),
        'series': _('The series'),
        'series_index': _('The series number. '
            'To get leading zeros use {series_index:0>3s} or '
            '{series_index:>3s} for leading spaces'),
        'rating': _('The rating'),
        'isbn': _('The ISBN'),
        'publisher': _('The publisher'),
        'timestamp': _('The date'),
        'pubdate': _('The published date'),
        'last_modified': _('The date when the metadata for this book record'
            ' was last modified'),
        'languages': _('The language(s) of this book'),
        'id': _('The calibre internal id')
}

FORMAT_ARGS = {}
for x in FORMAT_ARG_DESCS:
    FORMAT_ARGS[x] = ''


def find_plugboard(device_name, format, plugboards):
    cpb = None
    if format in plugboards:
        pb = plugboards[format]
        if device_name in pb:
            cpb = pb[device_name]
        elif plugboard_any_device_value in pb:
            cpb = pb[plugboard_any_device_value]
    if not cpb and plugboard_any_format_value in plugboards:
        pb = plugboards[plugboard_any_format_value]
        if device_name in pb:
            cpb = pb[device_name]
        elif plugboard_any_device_value in pb:
            cpb = pb[plugboard_any_device_value]
    if DEBUG:
        prints('Device using plugboard', format, device_name, cpb)
    return cpb


def config(defaults=None):
    if defaults is None:
        c = Config('save_to_disk', _('Options to control saving to disk'))
    else:
        c = StringConfig(defaults)

    x = c.add_opt
    x('update_metadata', default=True,
            help=_('Normally, calibre will update the metadata in the saved files from what is'
            ' in the calibre library. Makes saving to disk slower.'))
    x('write_opf', default=True,
            help=_('Normally, calibre will write the metadata into a separate OPF file along with the'
                ' actual e-book files.'))
    x('save_cover', default=True,
            help=_('Normally, calibre will save the cover in a separate file along with the '
                'actual e-book files.'))
    x('formats', default='all',
            help=_('Comma separated list of formats to save for each book.'
                ' By default all available formats are saved.'))
    x('template', default=DEFAULT_TEMPLATE,
            help=_('The template to control the filename and folder structure of the saved files. '
                'Default is "%(templ)s" which will save books into a per-author '
                'subfolder with filenames containing title and author. '
                'Available controls are: {%(controls)s}')%dict(
                    templ=DEFAULT_TEMPLATE, controls=', '.join(sorted(FORMAT_ARGS))))
    x('send_template', default=DEFAULT_SEND_TEMPLATE,
            help=_('The template to control the filename and folder structure of files '
                'sent to the device. '
                'Default is "%(templ)s" which will save books into a per-author '
                'folder with filenames containing title and author. '
                'Available controls are: {%(controls)s}')%dict(
                    templ=DEFAULT_SEND_TEMPLATE, controls=', '.join(FORMAT_ARGS)))
    x('asciiize', default=False,
            help=_('Have calibre convert all non English characters into English equivalents '
                'for the file names. This is useful if saving to a legacy filesystem '
                'without full support for Unicode filenames.'))
    x('timefmt', default='%b, %Y',
            help=_('The format in which to display dates. %(day)s - day,'
                ' %(month)s - month, %(mn)s - month number, %(year)s - year. Default is: %(default)s'
                )%dict(day='%d', month='%b', mn='%m', year='%Y', default='%b, %Y'))
    x('send_timefmt', default='%b, %Y',
            help=_('The format in which to display dates. %(day)s - day,'
                ' %(month)s - month, %(mn)s - month number, %(year)s - year. Default is: %(default)s'
                )%dict(day='%d', month='%b', mn='%m', year='%Y', default='%b, %Y'))
    x('to_lowercase', default=False,
            help=_('Convert paths to lowercase.'))
    x('replace_whitespace', default=False,
            help=_('Replace whitespace with underscores.'))
    x('single_dir', default=False,
            help=_('Save into a single folder, ignoring the template'
                ' folder structure'))
    x('save_extra_files', default=True, help=_(
        'Save any data files associated with the book when saving the book'))
    return c


def preprocess_template(template):
    template = template.replace('//', '/')
    template = template.replace('{author}', '{authors}')
    template = template.replace('{tag}', '{tags}')
    if not isinstance(template, str):
        template = template.decode(preferred_encoding, 'replace')
    return template


class Formatter(TemplateFormatter):
    '''
    Provides a format function that substitutes '' for any missing value
    '''

    def get_value(self, key, args, kwargs):
        if not isinstance(key, str) or key == '':
            return ''
        try:
            key = key.lower()
            try:
                b = self.book.get_user_metadata(key, False)
            except:
                traceback.print_exc()
                b = None
            if b is not None and b['datatype'] == 'composite':
                if key in self.composite_values:
                    return self.composite_values[key]
                self.composite_values[key] = 'RECURSIVE_COMPOSITE FIELD (S2D) ' + key
                self.composite_values[key] = \
                    self.evaluate(b['display']['composite_template'], [], kwargs, {})
                return self.composite_values[key]
            if key in kwargs:
                val = kwargs[key]
                if isinstance(val, list) or isinstance(val, FormatsList):
                    val = ','.join(val)
                return val.replace('/', '_').replace('\\', '_')
            return ''
        except:
            traceback.print_exc()
            return key


def get_component_metadata(template, mi, book_id, timefmt='%b %Y'):
    tsorder = tweaks['save_template_title_series_sorting']
    format_args = FORMAT_ARGS.copy()
    format_args.update(mi.all_non_none_fields())
    if mi.title:
        if tsorder == 'strictly_alphabetic':
            v = mi.title
        else:
            # title_sort might be missing or empty. Check both conditions
            v = mi.get('title_sort', None)
            if not v:
                v = title_sort(mi.title, order=tsorder)
        format_args['title'] = v
    if mi.authors:
        format_args['authors'] = mi.format_authors()
        format_args['author'] = format_args['authors']
    if mi.tags:
        format_args['tags'] = mi.format_tags()
        if format_args['tags'].startswith('/'):
            format_args['tags'] = format_args['tags'][1:]
    else:
        format_args['tags'] = ''
    if mi.series:
        format_args['series'] = title_sort(mi.series, order=tsorder)
        if mi.series_index is not None:
            format_args['series_index'] = mi.format_series_index()
    if mi.rating is not None:
        format_args['rating'] = mi.format_rating(divide_by=2.0)
    if mi.identifiers:
        format_args['identifiers'] = mi.format_field_extended('identifiers')[1]
    else:
        format_args['identifiers'] = ''

    if not is_date_undefined(mi.timestamp) and hasattr(mi.timestamp, 'timetuple'):
        format_args['timestamp'] = strftime(timefmt, mi.timestamp.timetuple())
    if not is_date_undefined(mi.pubdate) and hasattr(mi.pubdate, 'timetuple'):
        format_args['pubdate'] = strftime(timefmt, mi.pubdate.timetuple())
    else:
        format_args.pop('pubdate', None)
    if (hasattr(mi, 'last_modified') and not is_date_undefined(mi.last_modified) and
                hasattr(mi.last_modified, 'timetuple')):
        format_args['last_modified'] = strftime(timefmt, mi.last_modified.timetuple())

    format_args['id'] = str(book_id)
    # Now format the custom fields
    custom_metadata = mi.get_all_user_metadata(make_copy=False)
    for key in custom_metadata:
        if key in format_args:
            cm = custom_metadata[key]
            if cm['datatype'] == 'series':
                format_args[key] = title_sort(format_args[key], order=tsorder)
                if key+'_index' in format_args:
                    format_args[key+'_index'] = fmt_sidx(format_args[key+'_index'])
            elif cm['datatype'] == 'datetime':
                format_args[key] = strftime(timefmt, as_local_time(format_args[key]).timetuple())
            elif cm['datatype'] == 'bool':
                format_args[key] = _('yes') if format_args[key] else _('no')
            elif cm['datatype'] == 'rating':
                format_args[key] = mi.format_rating(format_args[key],
                        divide_by=2.0)
            elif cm['datatype'] in ['int', 'float']:
                if format_args[key] != 0:
                    format_args[key] = str(format_args[key])
                else:
                    format_args[key] = ''
    return format_args


def get_components(template, mi, book_id, timefmt='%b %Y', length=250,
        sanitize_func=ascii_filename, replace_whitespace=False,
        to_lowercase=False, safe_format=True, last_has_extension=True,
        single_dir=False):
    format_args = get_component_metadata(template, mi, book_id, timefmt)
    if safe_format:
        components = Formatter().safe_format(template, format_args, 'G_C-EXCEPTION!', mi)
    else:
        components = Formatter().unsafe_format(template, format_args, mi)
    components = [x.strip() for x in components.split('/')]
    components = [sanitize_func(x) for x in components if x]
    if not components:
        components = [str(book_id)]
    if to_lowercase:
        components = [x.lower() for x in components]
    if replace_whitespace:
        components = [re.sub(r'\s', '_', x) for x in components]

    if single_dir:
        components = components[-1:]
    return shorten_components_to(length, components, last_has_extension=last_has_extension)


def get_formats(available_formats, formats):
    available_formats = {x.lower().strip() for x in available_formats}
    if formats == 'all':
        asked_formats = available_formats
    else:
        asked_formats = {x.lower().strip() for x in formats.split(',')}
    return available_formats & asked_formats


def save_book_to_disk(book_id, db, root, opts, length):
    db = db.new_api
    mi = db.get_metadata(book_id, index_is_id=True)
    plugboards = db.pref('plugboards', {})
    formats = get_formats(db.formats(book_id), opts.formats)
    return do_save_book_to_disk(db, book_id, mi, plugboards,
        formats, root, opts, length)


def get_path_components(opts, mi, book_id, path_length):
    try:
        components = get_components(opts.template, mi, book_id, opts.timefmt, path_length,
            ascii_filename if opts.asciiize else sanitize_file_name,
            to_lowercase=opts.to_lowercase,
            replace_whitespace=opts.replace_whitespace, safe_format=False,
            last_has_extension=False, single_dir=opts.single_dir)
    except Exception as e:
        raise ValueError(_('Failed to calculate path for '
            'save to disk. Template: %(templ)s\n'
            'Error: %(err)s')%dict(templ=opts.template, err=e))
    if not components:
        raise ValueError(_('Template evaluation resulted in no'
            ' path components. Template: %s')%opts.template)
    return components


def update_metadata(mi, fmt, stream, plugboards, cdata, error_report=None, plugboard_cache=None):
    from calibre.ebooks.metadata.meta import set_metadata
    if error_report is not None:
        def report_error(mi, fmt, tb):
            error_report(fmt, tb)

    try:
        if plugboard_cache is not None:
            cpb = plugboard_cache[fmt]
        else:
            cpb = find_plugboard(plugboard_save_to_disk_value, fmt, plugboards)
        if cpb:
            newmi = mi.deepcopy_metadata()
            newmi.template_to_attribute(mi, cpb)
        else:
            newmi = mi
        if cdata:
            newmi.cover_data = ('jpg', cdata)
        set_metadata(stream, newmi, fmt, report_error=None if error_report is None else report_error)
    except:
        if error_report is None:
            prints('Failed to set metadata for the', fmt, 'format of', mi.title)
            traceback.print_exc()
        else:
            error_report(fmt, traceback.format_exc())


def do_save_book_to_disk(db, book_id, mi, plugboards,
        formats, root, opts, length, extra_files=()):
    originals = mi.cover, mi.pubdate, mi.timestamp
    formats_written = False
    try:
        if mi.pubdate:
            mi.pubdate = as_local_time(mi.pubdate)
        if mi.timestamp:
            mi.timestamp = as_local_time(mi.timestamp)

        components = get_path_components(opts, mi, book_id, length)
        base_path = os.path.join(root, *components)
        base_name = os.path.basename(base_path)
        dirpath = os.path.dirname(base_path)
        os.makedirs(dirpath, exist_ok=True)
        cdata = None
        if opts.save_cover:
            cdata = db.cover(book_id)
            if cdata:
                cpath = base_path + '.jpg'
                with open(make_long_path_useable(cpath), 'wb') as f:
                    f.write(cdata)
                mi.cover = base_name+'.jpg'
        if opts.write_opf:
            from calibre.ebooks.metadata.opf2 import metadata_to_opf
            opf = metadata_to_opf(mi)
            with open(make_long_path_useable(base_path+'.opf'), 'wb') as f:
                f.write(opf)
    finally:
        mi.cover, mi.pubdate, mi.timestamp = originals

    if extra_files and opts.save_extra_files:
        for relpath in extra_files:
            data_dest_path = os.path.abspath(os.path.join(dirpath, relpath))
            try:
                db.copy_extra_file_to(book_id, relpath, data_dest_path)
            except FileNotFoundError:
                os.makedirs(make_long_path_useable(os.path.dirname(data_dest_path)))
                db.copy_extra_file_to(book_id, relpath, data_dest_path)
    if not formats:
        return not formats_written, book_id, mi.title

    for fmt in formats:
        fmt_path = base_path+'.'+str(fmt)
        try:
            db.copy_format_to(book_id, fmt, fmt_path)
            formats_written = True
        except NoSuchFormat:
            continue
        if opts.update_metadata:
            with open(make_long_path_useable(fmt_path), 'r+b') as stream:
                update_metadata(mi, fmt, stream, plugboards, cdata)

    return not formats_written, book_id, mi.title


def sanitize_args(root, opts):
    if opts is None:
        opts = config().parse()
    root = os.path.abspath(root)

    opts.template = preprocess_template(opts.template)
    length = 255 if iswindows else 1024
    length -= 15
    length -= len(root)
    if length < 5:
        raise ValueError(f'{root!r} is too long.')
    return root, opts, length


def save_to_disk(db, ids, root, opts=None, callback=None):
    '''
    Save books from the database ``db`` to the path specified by ``root``.

    :param:`ids` iterable of book ids to save from the database.
    :param:`callback` is an optional callable that is called on after each
    book is processed with the arguments: id, title, failed, traceback.
    If the callback returns False, further processing is terminated and
    the function returns.
    :return: A list of failures. Each element of the list is a tuple
    (id, title, traceback)
    '''
    root, opts, length = sanitize_args(root, opts)
    failures = []
    for x in ids:
        tb = ''
        try:
            failed, id, title = save_book_to_disk(x, db, root, opts, length)
            tb = _('Requested formats not available')
        except:
            failed, id, title = True, x, db.title(x, index_is_id=True)
            tb = traceback.format_exc()
        if failed:
            failures.append((id, title, tb))
        if callable(callback):
            if not callback(int(id), title, failed, tb):
                break
    return failures


def read_serialized_metadata(data):
    from calibre.ebooks.metadata.opf2 import OPF
    from calibre.utils.date import parse_date
    mi = OPF(data['opf'], try_to_guess_cover=False, populate_spine=False, basedir=os.path.dirname(data['opf'])).to_book_metadata()
    try:
        mi.last_modified = parse_date(data['last_modified'])
    except:
        pass
    mi.cover, mi.cover_data = None, (None, None)
    cdata = None
    if 'cover' in data:
        with open(data['cover'], 'rb') as f:
            cdata = f.read()
    return mi, cdata


def update_serialized_metadata(book, common_data=None):
    # This is called from a worker process. It must not open the database.
    result = []
    plugboard_cache = common_data['plugboard_cache']
    load_user_template_functions(common_data['library_id'], common_data['template_functions'])
    from calibre.customize.ui import apply_null_metadata
    with apply_null_metadata:
        fmts = [fp.rpartition(os.extsep)[-1] for fp in book['fmts']]
        mi, cdata = read_serialized_metadata(book)

        def report_error(fmt, tb):
            result.append((fmt, tb))

        for fmt, fmtpath in zip(fmts, book['fmts']):
            try:
                with open(fmtpath, 'r+b') as stream:
                    update_metadata(mi, fmt, stream, (), cdata, error_report=report_error, plugboard_cache=plugboard_cache)
            except Exception:
                report_error(fmt, traceback.format_exc())

    return result
