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

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

import numbers
import os
import re
import traceback
from collections import defaultdict
from contextlib import suppress
from copy import deepcopy
from functools import partial

from calibre.constants import CONFIG_DIR_MODE, config_dir, filesystem_encoding, get_umask, iswindows, preferred_encoding
from calibre.utils.localization import _
from calibre.utils.resources import get_path as P
from polyglot.builtins import iteritems

plugin_dir = os.path.join(config_dir, 'plugins')


def parse_old_style(src):
    import pickle as cPickle
    options = {'cPickle':cPickle}
    try:
        if not isinstance(src, str):
            src = src.decode('utf-8')
        src = re.sub(r'PyQt(?:4|5).QtCore', r'PyQt6.QtCore', src)
        src = re.sub(r'cPickle\.loads\(([\'"])', r'cPickle.loads(b\1', src)
        exec(src, options)
    except Exception as err:
        try:
            print(f'Failed to parse old style options string with error: {err}')
        except Exception:
            pass
    return options


def to_json(obj):
    import datetime
    if isinstance(obj, bytearray):
        from base64 import standard_b64encode
        return {'__class__': 'bytearray',
                '__value__': standard_b64encode(bytes(obj)).decode('ascii')}
    if isinstance(obj, datetime.datetime):
        from calibre.utils.date import isoformat
        return {'__class__': 'datetime.datetime',
                '__value__': isoformat(obj, as_utc=True)}
    if isinstance(obj, (set, frozenset)):
        return {'__class__': 'set', '__value__': tuple(obj)}
    if isinstance(obj, bytes):
        return obj.decode('utf-8')
    if hasattr(obj, 'toBase64'):  # QByteArray
        return {'__class__': 'bytearray',
                '__value__': bytes(obj.toBase64()).decode('ascii')}
    v = getattr(obj, 'value', None)
    if isinstance(v, int):  # Possibly an enum with integer values like all the Qt enums
        return v
    raise TypeError(repr(obj) + ' is not JSON serializable')


def safe_to_json(obj):
    try:
        return to_json(obj)
    except Exception:
        pass


def from_json(obj):
    custom = obj.get('__class__')
    if custom is not None:
        if custom == 'bytearray':
            from base64 import standard_b64decode
            return bytearray(standard_b64decode(obj['__value__'].encode('ascii')))
        if custom == 'datetime.datetime':
            from calibre.utils.iso8601 import parse_iso8601
            return parse_iso8601(obj['__value__'], assume_utc=True)
        if custom == 'set':
            return set(obj['__value__'])
    return obj


def force_unicode(x):
    try:
        return x.decode('mbcs' if iswindows else preferred_encoding)
    except UnicodeDecodeError:
        try:
            return x.decode(filesystem_encoding)
        except UnicodeDecodeError:
            return x.decode('utf-8', 'replace')


def force_unicode_recursive(obj):
    if isinstance(obj, bytes):
        return force_unicode(obj)
    if isinstance(obj, (list, tuple)):
        return type(obj)(map(force_unicode_recursive, obj))
    if isinstance(obj, dict):
        return {force_unicode_recursive(k): force_unicode_recursive(v) for k, v in iteritems(obj)}
    return obj


def json_dumps(obj, ignore_unserializable=False):
    import json
    try:
        ans = json.dumps(obj, indent=2, default=safe_to_json if ignore_unserializable else to_json, sort_keys=True, ensure_ascii=False)
    except UnicodeDecodeError:
        obj = force_unicode_recursive(obj)
        ans = json.dumps(obj, indent=2, default=safe_to_json if ignore_unserializable else to_json, sort_keys=True, ensure_ascii=False)
    if not isinstance(ans, bytes):
        ans = ans.encode('utf-8')
    return ans


def json_loads(raw):
    import json
    if isinstance(raw, bytes):
        raw = raw.decode('utf-8')
    return json.loads(raw, object_hook=from_json)


def make_config_dir():
    if not os.path.exists(plugin_dir):
        os.makedirs(plugin_dir, mode=CONFIG_DIR_MODE)


class Option:

    def __init__(self, name, switches=[], help='', type=None, choices=None,
                 check=None, group=None, default=None, action=None, metavar=None):
        if choices:
            type = 'choice'

        self.name     = name
        self.switches = switches
        self.help     = help.replace('%default', repr(default)) if help else None
        self.type     = type
        if self.type is None and action is None and choices is None:
            if isinstance(default, float):
                self.type = 'float'
            elif isinstance(default, numbers.Integral) and not isinstance(default, bool):
                self.type = 'int'

        self.choices  = choices
        self.check    = check
        self.group    = group
        self.default  = default
        self.action   = action
        self.metavar  = metavar

    def __eq__(self, other):
        return self.name == getattr(other, 'name', other)

    def __repr__(self):
        return 'Option: '+self.name

    def __str__(self):
        return repr(self)


class OptionValues:

    def copy(self):
        return deepcopy(self)


class OptionSet:

    OVERRIDE_PAT = re.compile(r'#{3,100} Override Options #{15}(.*?)#{3,100} End Override #{3,100}',
                              re.DOTALL|re.IGNORECASE)

    def __init__(self, description=''):
        self.description = description
        self.defaults = {}
        self.preferences = []
        self.group_list  = []
        self.groups      = {}
        self.set_buffer  = {}
        self.loads_pat = None

    def has_option(self, name_or_option_object):
        if name_or_option_object in self.preferences:
            return True
        for p in self.preferences:
            if p.name == name_or_option_object:
                return True
        return False

    def get_option(self, name_or_option_object):
        idx = self.preferences.index(name_or_option_object)
        if idx > -1:
            return self.preferences[idx]
        for p in self.preferences:
            if p.name == name_or_option_object:
                return p

    def add_group(self, name, description=''):
        if name in self.group_list:
            raise ValueError(f'A group by the name {name} already exists in this set')
        self.groups[name] = description
        self.group_list.append(name)
        return partial(self.add_opt, group=name)

    def update(self, other):
        for name in other.groups.keys():
            self.groups[name] = other.groups[name]
            if name not in self.group_list:
                self.group_list.append(name)
        for pref in other.preferences:
            if pref in self.preferences:
                self.preferences.remove(pref)
            self.preferences.append(pref)

    def smart_update(self, opts1, opts2):
        '''
        Updates the preference values in opts1 using only the non-default preference values in opts2.
        '''
        for pref in self.preferences:
            new = getattr(opts2, pref.name, pref.default)
            if new != pref.default:
                setattr(opts1, pref.name, new)

    def remove_opt(self, name):
        if name in self.preferences:
            self.preferences.remove(name)

    def add_opt(self, name, switches=[], help=None, type=None, choices=None,
                 group=None, default=None, action=None, metavar=None):
        '''
        Add an option to this section.

        :param name:       The name of this option. Must be a valid Python identifier.
                           Must also be unique in this OptionSet and all its subsets.
        :param switches:   List of command line switches for this option
                           (as supplied to :module:`optparse`). If empty, this
                           option will not be added to the command line parser.
        :param help:       Help text.
        :param type:       Type checking of option values. Supported types are:
                           `None, 'choice', 'complex', 'float', 'int', 'string'`.
        :param choices:    List of strings or `None`.
        :param group:      Group this option belongs to. You must previously
                           have created this group with a call to :method:`add_group`.
        :param default:    The default value for this option.
        :param action:     The action to pass to optparse. Supported values are:
                           `None, 'count'`. For choices and boolean options,
                           action is automatically set correctly.
        '''
        pref = Option(name, switches=switches, help=help, type=type, choices=choices,
                 group=group, default=default, action=action, metavar=None)
        if group is not None and group not in self.groups.keys():
            raise ValueError(f'Group {group} has not been added to this section')
        if pref in self.preferences:
            raise ValueError(f'An option with the name {name} already exists in this set.')
        self.preferences.append(pref)
        self.defaults[name] = default

    def retranslate_help(self):
        t = _
        for opt in self.preferences:
            if opt.help:
                opt.help = t(opt.help)
                if opt.name == 'use_primary_find_in_search':
                    opt.help = opt.help.format('ñ')

    def option_parser(self, user_defaults=None, usage='', gui_mode=False):
        from calibre.utils.config import OptionParser
        parser = OptionParser(usage, gui_mode=gui_mode)
        groups = defaultdict(lambda: parser)
        for group, desc in self.groups.items():
            groups[group] = parser.add_option_group(group.upper(), desc)

        for pref in self.preferences:
            if not pref.switches:
                continue
            g = groups[pref.group]
            action = pref.action
            if action is None:
                action = 'store'
                if pref.default is True or pref.default is False:
                    action = 'store_' + ('false' if pref.default else 'true')
            args = dict(
                        dest=pref.name,
                        help=pref.help,
                        metavar=pref.metavar,
                        type=pref.type,
                        choices=pref.choices,
                        default=getattr(user_defaults, pref.name, pref.default),
                        action=action,
                        )
            g.add_option(*pref.switches, **args)

        return parser

    def get_override_section(self, src):
        match = self.OVERRIDE_PAT.search(src)
        if match:
            return match.group()
        return ''

    def parse_string(self, src):
        options = {}
        if src:
            is_old_style = (isinstance(src, bytes) and src.startswith(b'#')) or (isinstance(src, str) and src.startswith('#'))
            if is_old_style:
                options = parse_old_style(src)
            else:
                try:
                    options = json_loads(src)
                    if not isinstance(options, dict):
                        raise Exception('options is not a dictionary')
                except Exception as err:
                    try:
                        print(f'Failed to parse options string with error: {err}')
                    except Exception:
                        pass
        opts = OptionValues()
        for pref in self.preferences:
            val = options.get(pref.name, pref.default)
            formatter = __builtins__.get(pref.type, None)
            if callable(formatter):
                val = formatter(val)
            setattr(opts, pref.name, val)

        return opts

    def serialize(self, opts, ignore_unserializable=False):
        data = {pref.name: getattr(opts, pref.name, pref.default) for pref in self.preferences}
        return json_dumps(data, ignore_unserializable=ignore_unserializable)


class ConfigInterface:

    def __init__(self, description):
        self.option_set       = OptionSet(description=description)
        self.add_opt          = self.option_set.add_opt
        self.add_group        = self.option_set.add_group
        self.remove_opt       = self.remove = self.option_set.remove_opt
        self.parse_string     = self.option_set.parse_string
        self.get_option       = self.option_set.get_option
        self.preferences      = self.option_set.preferences

    def update(self, other):
        self.option_set.update(other.option_set)

    def option_parser(self, usage='', gui_mode=False):
        return self.option_set.option_parser(user_defaults=self.parse(),
                                             usage=usage, gui_mode=gui_mode)

    def smart_update(self, opts1, opts2):
        self.option_set.smart_update(opts1, opts2)


def retry_on_fail(func, *args, count=10, sleep_time=0.2):
    import time
    ERROR_SHARING_VIOLATION = 32
    ACCESS_DENIED = 5
    for i in range(count):
        try:
            return func(*args)
        except FileNotFoundError:
            raise
        except OSError as e:
            # Windows stupidly gives us an ACCESS_DENIED rather than a
            # ERROR_SHARING_VIOLATION if the file is open
            if not iswindows or i > count - 2 or e.winerror not in (ERROR_SHARING_VIOLATION, ACCESS_DENIED):
                raise
            # Try the operation repeatedly in case something like a virus
            # scanner has opened one of the files (I love windows)
            time.sleep(sleep_time)


def read_data(file_path):
    def r():
        with open(file_path, 'rb') as f:
            return f.read()
    return retry_on_fail(r)


def commit_data(file_path, data):
    import tempfile
    bdir = os.path.dirname(file_path)
    os.makedirs(bdir, exist_ok=True, mode=CONFIG_DIR_MODE)
    try:
        with tempfile.NamedTemporaryFile(dir=bdir, prefix=os.path.basename(file_path).split('.')[0] + '-atomic-', delete=False) as f:
            if hasattr(os, 'fchmod'):
                os.fchmod(f.fileno(), 0o666 & ~get_umask())
            f.write(data)
        retry_on_fail(os.replace, f.name, file_path)
    finally:
        with suppress(FileNotFoundError, NameError):
            os.remove(f.name)


class Config(ConfigInterface):
    '''
    A file based configuration.
    '''

    def __init__(self, basename, description=''):
        ConfigInterface.__init__(self, description)
        self.filename_base = basename

    @property
    def config_file_path(self):
        return os.path.join(config_dir, self.filename_base + '.py.json')

    def parse(self):
        src = ''
        migrate = False
        path = self.config_file_path
        with suppress(FileNotFoundError):
            src_bytes = read_data(path)
            try:
                src = src_bytes.decode('utf-8')
            except ValueError:
                print('Failed to parse', path)
                traceback.print_exc()
        if not src:
            path = path.rpartition('.')[0]
            from calibre.utils.shared_file import share_open
            try:
                with share_open(path, 'rb') as f:
                    src = f.read().decode('utf-8')
            except Exception:
                pass
            else:
                migrate = bool(src)
        ans = self.option_set.parse_string(src)
        if migrate:
            new_src = self.option_set.serialize(ans, ignore_unserializable=True)
            commit_data(self.config_file_path, new_src)
        return ans

    def set(self, name, val):
        if not self.option_set.has_option(name):
            raise ValueError(f'The option {name} is not defined.')
        if not os.path.exists(config_dir):
            make_config_dir()
        src = b''
        with suppress(FileNotFoundError):
            src = read_data(self.config_file_path)
        opts = self.option_set.parse_string(src)
        setattr(opts, name, val)
        src = self.option_set.serialize(opts)
        if isinstance(src, str):
            src = src.encode('utf-8')
        commit_data(self.config_file_path, src)


class StringConfig(ConfigInterface):
    '''
    A string based configuration
    '''

    def __init__(self, src, description=''):
        ConfigInterface.__init__(self, description)
        self.set_src(src)

    def set_src(self, src):
        self.src = src
        if isinstance(self.src, bytes):
            self.src = self.src.decode('utf-8')

    def parse(self):
        return self.option_set.parse_string(self.src)

    def set(self, name, val):
        if not self.option_set.has_option(name):
            raise ValueError(f'The option {name} is not defined.')
        opts = self.option_set.parse_string(self.src)
        setattr(opts, name, val)
        self.set_src(self.option_set.serialize(opts))


class ConfigProxy:
    '''
    A Proxy to minimize file reads for widely used config settings
    '''

    def __init__(self, config):
        self.__config = config
        self.__opts   = None

    @property
    def defaults(self):
        return self.__config.option_set.defaults

    def refresh(self):
        self.__opts = self.__config.parse()

    def retranslate_help(self):
        self.__config.option_set.retranslate_help()

    def __getitem__(self, key):
        return self.get(key)

    def __setitem__(self, key, val):
        return self.set(key, val)

    def __delitem__(self, key):
        self.set(key, self.defaults[key])

    def get(self, key):
        if self.__opts is None:
            self.refresh()
        return getattr(self.__opts, key)

    def set(self, key, val):
        if self.__opts is None:
            self.refresh()
        setattr(self.__opts, key, val)
        return self.__config.set(key, val)

    def help(self, key):
        return self.__config.get_option(key).help


def create_global_prefs(conf_obj=None):
    c = Config('global', 'calibre wide preferences') if conf_obj is None else conf_obj
    c.add_opt('database_path',
              default=os.path.expanduser('~/library1.db'),
              help=_('Path to the database in which books are stored'))
    c.add_opt('filename_pattern', default='(?P<title>.+) - (?P<author>[^_]+)',
              help=_('Pattern to guess metadata from filenames'))
    c.add_opt('isbndb_com_key', default='',
              help=_('Access key for isbndb.com'))
    c.add_opt('network_timeout', default=5,
              help=_('Default timeout for network operations (seconds)'))
    c.add_opt('library_path', default=None,
              help=_('Path to folder in which your library of books is stored'))
    c.add_opt('language', default=None,
              help=_('The language in which to display the user interface'))
    c.add_opt('output_format', default='EPUB',
              help=_('The default output format for e-book conversions. When auto-converting'
                  ' to send to a device this can be overridden by individual device preferences.'
                  ' These can be changed by right clicking the device icon in calibre and'
                  ' choosing "Configure".'))
    c.add_opt('input_format_order', default=['EPUB', 'AZW3', 'MOBI', 'LIT', 'PRC',
        'FB2', 'HTML', 'HTM', 'XHTM', 'SHTML', 'XHTML', 'ZIP', 'DOCX', 'ODT', 'RTF', 'PDF',
        'TXT'],
              help=_('Ordered list of formats to prefer for input.'))
    c.add_opt('read_file_metadata', default=True,
              help=_('Read metadata from files'))
    c.add_opt('worker_process_priority', default='normal',
              help=_('The priority of worker processes. A higher priority '
                  'means they run faster and consume more resources. '
                  'Most tasks like conversion/news download/adding books/etc. '
                  'are affected by this setting.'))
    c.add_opt('swap_author_names', default=False,
            help=_('Swap author first and last names when reading metadata'))
    c.add_opt('add_formats_to_existing', default=False,
            help=_('Add new formats to existing book records'))
    c.add_opt('check_for_dupes_on_ctl', default=False,
            help=_('Check for duplicates when copying to another library'))
    c.add_opt('installation_uuid', default=None, help='Installation UUID')
    c.add_opt('new_book_tags', default=[], help=_('Tags to apply to books added to the library'))
    c.add_opt('mark_new_books', default=False, help=_(
        'Mark newly added books. The mark is a temporary mark that is automatically removed when calibre is restarted.'))

    # these are here instead of the gui preferences because calibredb and
    # calibre server can execute searches
    c.add_opt('saved_searches', default={}, help=_('List of named saved searches'))
    c.add_opt('user_categories', default={}, help=_('User-created Tag browser categories'))
    c.add_opt('manage_device_metadata', default='manual',
        help=_('How and when calibre updates metadata on the device.'))
    c.add_opt('limit_search_columns', default=False,
            help=_('When searching for text without using lookup '
            'prefixes, as for example, Red instead of title:Red, '
            'limit the columns searched to those named below.'))
    c.add_opt('limit_search_columns_to',
            default=['title', 'authors', 'tags', 'series', 'publisher'],
            help=_('Choose columns to be searched when not using prefixes, '
                'as for example, when searching for Red instead of '
                'title:Red. Enter a list of search/lookup names '
                'separated by commas. Only takes effect if you set the option '
                'to limit search columns above.'))
    c.add_opt('use_primary_find_in_search', default=True,
            help=_('Characters typed in the search box will match their '
                   'accented versions, based on the language you have chosen '
                   'for the calibre interface. For example, in '
                   'English, searching for n will match both {} and n, but if '
                   'your language is Spanish it will only match n. Note that '
                   'this is much slower than a simple search on very large '
                   'libraries. Also, this option will have no effect if you turn '
                   'on case-sensitive searching.'))
    c.add_opt('case_sensitive', default=False, help=_(
        'Make searches case-sensitive'))
    c.add_opt('numeric_collation', default=False,
            help=_('Recognize numbers inside text when sorting. Setting this '
                   'means that when sorting on text fields like title the text "Book 2"'
                   'will sort before the text "Book 100". Note that setting this '
                   'can cause problems with text that starts with numbers and is '
                   'a little slower.'))

    c.add_opt('migrated', default=False, help="For Internal use. Don't modify.")
    return c


prefs = ConfigProxy(create_global_prefs())
if prefs['installation_uuid'] is None:
    import uuid
    prefs['installation_uuid'] = str(uuid.uuid4())


# Read tweaks

def tweaks_file():
    return os.path.join(config_dir, 'tweaks.json')


def make_unicode(obj):
    if isinstance(obj, bytes):
        try:
            return obj.decode('utf-8')
        except UnicodeDecodeError:
            return obj.decode(preferred_encoding, errors='replace')
    if isinstance(obj, (list, tuple)):
        return list(map(make_unicode, obj))
    if isinstance(obj, dict):
        return {make_unicode(k): make_unicode(v) for k, v in iteritems(obj)}
    return obj


def normalize_tweak(val):
    if isinstance(val, (list, tuple)):
        return tuple(map(normalize_tweak, val))
    if isinstance(val, dict):
        return {k: normalize_tweak(v) for k, v in iteritems(val)}
    return val


def write_custom_tweaks(tweaks_dict):
    make_config_dir()
    tweaks_dict = make_unicode(tweaks_dict)
    changed_tweaks = {}
    default_tweaks = exec_tweaks(default_tweaks_raw())
    for key, cval in iteritems(tweaks_dict):
        if key in default_tweaks and normalize_tweak(cval) == normalize_tweak(default_tweaks[key]):
            continue
        changed_tweaks[key] = cval
    raw = json_dumps(changed_tweaks)
    with open(tweaks_file(), 'wb') as f:
        f.write(raw)


def exec_tweaks(path):
    if isinstance(path, bytes):
        raw = path
        fname = '<string>'
    else:
        with open(path, 'rb') as f:
            raw = f.read()
            fname = f.name
    code = compile(raw, fname, 'exec')
    l = {}
    g = {'__file__': fname}
    exec(code, g, l)
    return l


def read_custom_tweaks():
    make_config_dir()
    tf = tweaks_file()
    ans = {}
    if os.path.exists(tf):
        with open(tf, 'rb') as f:
            raw = f.read()
        raw = raw.strip()
        if not raw:
            return ans
        try:
            return json_loads(raw)
        except Exception:
            import traceback
            traceback.print_exc()
            return ans
    old_tweaks_file = tf.rpartition('.')[0] + '.py'
    if os.path.exists(old_tweaks_file):
        ans = exec_tweaks(old_tweaks_file)
        ans = make_unicode(ans)
        write_custom_tweaks(ans)
    return ans


def default_tweaks_raw():
    return P('default_tweaks.py', data=True, allow_user_override=False)


def read_tweaks():
    default_tweaks = exec_tweaks(default_tweaks_raw())
    try:
        custom_tweaks = read_custom_tweaks()
    except Exception:
        custom_tweaks = {}
    default_tweaks.update(custom_tweaks)
    return default_tweaks


tweaks = read_tweaks()


def migrate_tweaks_to_prefs():
    # This must happen after the tweaks are loaded
    # Migrate the numeric_collation tweak
    if 'numeric_collation' in tweaks:
        prefs['numeric_collation'] = tweaks.get('numeric_collation', False)
        tweaks.pop('numeric_collation')
        write_custom_tweaks(tweaks)


migrate_tweaks_to_prefs()


def reset_tweaks_to_default():
    default_tweaks = exec_tweaks(default_tweaks_raw())
    tweaks.clear()
    tweaks.update(default_tweaks)


class Tweak:

    def __init__(self, name, value):
        self.name, self.value = name, value

    def __enter__(self):
        self.origval = tweaks[self.name]
        tweaks[self.name] = self.value

    def __exit__(self, *args):
        tweaks[self.name] = self.origval
