# Copyright (C) 2019-2024 Valéry Febvre
# SPDX-License-Identifier: GPL-3.0-only or GPL-3.0-or-later
# Author: Valéry Febvre <vfebvre@easter-eggs.com>

from functools import cache
import json
import logging
import os
import shutil

from gi.repository import Gio
import natsort
import sqlite3

from komikku.utils import get_cached_data_dir
from komikku.utils import get_data_dir
from komikku.utils import is_flatpak

logger = logging.getLogger(__name__)

VERSION = 14


def adapt_json(data):
    return (json.dumps(data, sort_keys=True)).encode()


def convert_json(blob):
    return json.loads(blob.decode())


sqlite3.register_adapter(dict, adapt_json)
sqlite3.register_adapter(list, adapt_json)
sqlite3.register_adapter(tuple, adapt_json)
sqlite3.register_converter('json', convert_json)


def backup_db():
    db_path = get_db_path()
    if os.path.exists(db_path) and check_db():
        logger.info('Save a DB backup')
        shutil.copyfile(db_path, get_db_backup_path())


def check_db():
    db_conn = create_db_connection()

    if db_conn:
        try:
            res = db_conn.execute('PRAGMA integrity_check').fetchone()  # PRAGMA quick_check

            fk_violations = len(db_conn.execute('PRAGMA foreign_key_check').fetchall())

            ret = res[0] == 'ok' and fk_violations == 0
        except sqlite3.DatabaseError:
            logger.exception('Failed to check DB')
            ret = False

        db_conn.close()

    return ret


def clear_cached_data(manga_in_use=None):
    # Clear chapters cache
    cache_dir_path = get_cached_data_dir()
    for server_name in os.listdir(cache_dir_path):
        server_dir_path = os.path.join(cache_dir_path, server_name)
        if manga_in_use and manga_in_use.path.startswith(server_dir_path):
            for manga_name in os.listdir(server_dir_path):
                manga_dir_path = os.path.join(server_dir_path, manga_name)
                if manga_dir_path != manga_in_use.path:
                    shutil.rmtree(manga_dir_path)
        else:
            shutil.rmtree(server_dir_path)

    # Clear database
    db_conn = create_db_connection()
    with db_conn:
        if manga_in_use:
            db_conn.execute('DELETE FROM mangas WHERE in_library != 1 AND id != ?', (manga_in_use.id, ))
        else:
            db_conn.execute('DELETE FROM mangas WHERE in_library != 1')

    db_conn.close()


def collate_natsort(value1, value2):
    lst = natsort.natsorted([value1, value2], alg=natsort.ns.INT | natsort.ns.IC)
    return -1 if lst[0] == value1 else 1


def create_db_connection():
    con = sqlite3.connect(get_db_path(), detect_types=sqlite3.PARSE_DECLTYPES)
    if con is None:
        logger.error('Can not create the database connection')
        return None

    con.row_factory = sqlite3.Row

    # Enable integrity constraint
    con.execute('PRAGMA foreign_keys = ON')

    # Add natural sort collation
    con.create_collation('natsort', collate_natsort)

    return con


def execute_sql(conn, sql):
    try:
        c = conn.cursor()
        c.execute(sql)
        conn.commit()
        c.close()
    except Exception as e:
        print('SQLite-error:', e)
        return False
    else:
        return True


@cache
def get_db_path():
    app_profile = Gio.Application.get_default().profile

    if is_flatpak() and app_profile == 'beta':
        # In Flathub beta version share same data folder with stable version:
        # ~/.var/app/info.febvre.Komikku/data/
        # So, DB files must have distinct names
        name = 'komikku_beta.db'
    else:
        name = 'komikku.db'

    return os.path.join(get_data_dir(), name)


@cache
def get_db_backup_path():
    app_profile = Gio.Application.get_default().profile

    if is_flatpak() and app_profile == 'beta':
        name = 'komikku_beta_backup.db'
    else:
        name = 'komikku_backup.db'

    return os.path.join(get_data_dir(), name)


def init_db():
    db_path = get_db_path()
    db_backup_path = get_db_backup_path()
    if os.path.exists(db_path) and os.path.exists(db_backup_path) and not check_db():
        # Restore backup
        logger.info('Restore DB from backup')
        shutil.copyfile(db_backup_path, db_path)

    sql_create_mangas_table = """CREATE TABLE IF NOT EXISTS mangas (
        id integer PRIMARY KEY,
        slug text NOT NULL,
        url text, -- only used in case slug can't be used to forge the url
        server_id text NOT NULL,
        in_library integer,
        name text NOT NULL,
        authors json,
        scanlators json,
        genres json,
        synopsis text,
        status text,
        background_color text,
        borders_crop integer,
        landscape_zoom integer,
        page_numbering integer,
        reading_mode text,
        scaling text,
        scaling_filter text,
        sort_order text,
        last_read timestamp,
        last_update timestamp,
        tracking json,
        UNIQUE (slug, server_id)
    );"""

    sql_create_chapters_table = """CREATE TABLE IF NOT EXISTS chapters (
        id integer PRIMARY KEY,
        manga_id integer REFERENCES mangas(id) ON DELETE CASCADE,
        slug text NOT NULL,
        url text, -- only used in case slug can't be used to forge the url
        title text NOT NULL,
        scanlators json,
        pages json,
        date date,
        num text,
        num_volume text,
        rank integer NOT NULL,
        downloaded integer NOT NULL,
        recent integer NOT NULL,
        read_progress text,
        read integer NOT NULL,
        last_page_read_index integer,
        last_read timestamp,
        UNIQUE (slug, manga_id)
    );"""

    sql_create_downloads_table = """CREATE TABLE IF NOT EXISTS downloads (
        id integer PRIMARY KEY,
        chapter_id integer REFERENCES chapters(id) ON DELETE CASCADE,
        status text NOT NULL,
        percent float NOT NULL,
        errors integer DEFAULT 0,
        date timestamp NOT NULL,
        UNIQUE (chapter_id)
    );"""

    sql_create_categories_table = """CREATE TABLE IF NOT EXISTS categories (
        id integer PRIMARY KEY,
        label text NOT NULL,
        UNIQUE (label)
    );"""

    sql_create_categories_mangas_association_table = """CREATE TABLE IF NOT EXISTS categories_mangas_association (
        category_id integer REFERENCES categories(id) ON DELETE CASCADE,
        manga_id integer REFERENCES mangas(id) ON DELETE CASCADE,
        UNIQUE (category_id, manga_id)
    );"""

    sql_create_indexes = [
        'CREATE INDEX IF NOT EXISTS idx_categories_mangas_association_manga ON categories_mangas_association(manga_id);',
        'CREATE INDEX IF NOT EXISTS idx_chapters_date ON chapters(manga_id, date);',
        'CREATE INDEX IF NOT EXISTS idx_chapters_downloaded ON chapters(manga_id, downloaded, read);',
        'CREATE INDEX IF NOT EXISTS idx_chapters_last_read ON chapters(last_read);',
        'CREATE INDEX IF NOT EXISTS idx_chapters_rank ON chapters(manga_id, rank);',
        'CREATE INDEX IF NOT EXISTS idx_chapters_read ON chapters(manga_id, read);',
        'CREATE INDEX IF NOT EXISTS idx_chapters_recent ON chapters(manga_id, recent);',
        'CREATE INDEX IF NOT EXISTS idx_downloads_date ON downloads(date);',
        'CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(date, status);',
        'CREATE INDEX IF NOT EXISTS idx_mangas_in_library ON mangas(in_library, last_read DESC);',
    ]

    db_conn = create_db_connection()
    if db_conn is not None:
        db_version = db_conn.execute('PRAGMA user_version').fetchone()[0]

        execute_sql(db_conn, sql_create_mangas_table)
        execute_sql(db_conn, sql_create_chapters_table)
        execute_sql(db_conn, sql_create_downloads_table)
        execute_sql(db_conn, sql_create_categories_table)
        execute_sql(db_conn, sql_create_categories_mangas_association_table)
        for sql_create_index in sql_create_indexes:
            execute_sql(db_conn, sql_create_index)

        if db_version == 0:
            # First launch
            db_conn.execute('PRAGMA user_version = {0}'.format(VERSION))

        if 0 < db_version <= 1:
            # Version 0.10.0
            if execute_sql(db_conn, 'ALTER TABLE downloads ADD COLUMN errors integer DEFAULT 0;'):
                db_conn.execute('PRAGMA user_version = {0}'.format(2))

        if 0 < db_version <= 2:
            # Version 0.12.0
            if execute_sql(db_conn, 'ALTER TABLE mangas ADD COLUMN borders_crop integer;'):
                db_conn.execute('PRAGMA user_version = {0}'.format(3))

        if 0 < db_version <= 4:
            # Version 0.16.0
            if execute_sql(db_conn, 'ALTER TABLE chapters ADD COLUMN scanlators json;'):
                db_conn.execute('PRAGMA user_version = {0}'.format(5))

        if 0 < db_version <= 5:
            # Version 0.22.0
            if execute_sql(db_conn, 'ALTER TABLE mangas RENAME COLUMN reading_direction TO reading_mode;'):
                db_conn.execute('PRAGMA user_version = {0}'.format(6))

        if 0 < db_version <= 6:
            # Version 0.25.0
            if execute_sql(db_conn, sql_create_categories_table) and execute_sql(db_conn, sql_create_categories_mangas_association_table):
                db_conn.execute('PRAGMA user_version = {0}'.format(7))

        if 0 < db_version <= 7:
            # Version 0.31.0
            ids_mapping = dict(
                jaiminisbox__old='jaiminisbox',
                kireicake='kireicake:jaiminisbox',
                lupiteam='lupiteam:jaiminisbox',
                tuttoanimemanga='tuttoanimemanga:jaiminisbox',

                readcomicsonline='readcomicsonline:hatigarmscans',

                hatigarmscans__old='hatigarmscans',

                edelgardescans='edelgardescans:genkan',
                hatigarmscans='hatigarmscans:genkan',
                hunlightscans='hunlightscans:genkan',
                leviatanscans__old='leviatanscans:genkan',
                leviatanscans_es_old='leviatanscans_es:genkan',
                oneshotscans__old='oneshotscans:genkan',
                reaperscans='reaperscans:genkan',
                thenonamesscans='thenonamesscans:genkan',
                zeroscans='zeroscans:genkan',

                akumanga='akumanga:madara',
                aloalivn='aloalivn:madara',
                apollcomics='apollcomics:madara',
                araznovel='araznovel:madara',
                argosscan='argosscan:madara',
                atikrost='atikrost:madara',
                romance24h='romance24h:madara',
                wakascan='wakascan:madara',
            )
            res = True
            for new, old in ids_mapping.items():
                res &= execute_sql(db_conn, f"UPDATE mangas SET server_id = '{new}' WHERE server_id = '{old}';")  # noqa: E702, E231

            if res:
                db_conn.execute('PRAGMA user_version = {0}'.format(8))

        if 0 < db_version <= 8:
            # Version 0.32.0
            if execute_sql(db_conn, 'ALTER TABLE mangas ADD COLUMN page_numbering integer;'):
                db_conn.execute('PRAGMA user_version = {0}'.format(9))

        if 0 < db_version <= 9:
            # Version 0.35.0
            execute_sql(db_conn, "UPDATE mangas SET server_id = 'reaperscans__old' WHERE server_id = 'reaperscans';")

            if execute_sql(db_conn, 'ALTER TABLE chapters ADD COLUMN last_read timestamp;'):
                db_conn.execute('PRAGMA user_version = {0}'.format(10))

        if 0 < db_version <= 10:
            # Version 1.0.0
            execute_sql(db_conn, 'ALTER TABLE mangas ADD COLUMN landscape_zoom integer;')
            execute_sql(db_conn, 'ALTER TABLE chapters ADD COLUMN read_progress text;')

            # Chapters: move reading status of pages in a new 'read_progress' field
            ids = []
            data = []
            manga_rows = db_conn.execute('SELECT id FROM mangas').fetchall()
            with db_conn:
                for manga_row in manga_rows:
                    chapter_rows = db_conn.execute('SELECT * FROM chapters WHERE manga_id = ?', (manga_row['id'],)).fetchall()
                    for chapter_row in chapter_rows:
                        if not chapter_row['pages']:
                            continue

                        read_progress = ''
                        for page in chapter_row['pages']:
                            read = page.pop('read', False)
                            read_progress += str(int(read))
                        if '1' in read_progress and '0' in read_progress:
                            ids.append(chapter_row['id'])
                            data.append({'pages': chapter_row['pages'], 'read_progress': read_progress})

                if ids:
                    update_rows(db_conn, 'chapters', ids, data)

                db_conn.execute('PRAGMA user_version = {0}'.format(11))

        if 0 < db_version <= 11:
            # Version 1.16.0
            execute_sql(db_conn, 'ALTER TABLE mangas ADD COLUMN in_library integer;')
            execute_sql(db_conn, 'UPDATE mangas SET in_library = 1;')
            db_conn.execute('PRAGMA user_version = {0}'.format(12))

        if 0 < db_version <= 12:
            # Version 1.54.0
            execute_sql(db_conn, 'ALTER TABLE mangas ADD COLUMN scaling_filter text;')
            db_conn.execute('PRAGMA user_version = {0}'.format(13))

        if 0 < db_version <= 13:
            # Version 1.60.0
            execute_sql(db_conn, 'ALTER TABLE mangas ADD COLUMN tracking json;')
            execute_sql(db_conn, 'ALTER TABLE chapters ADD COLUMN num text;')
            execute_sql(db_conn, 'ALTER TABLE chapters ADD COLUMN num_volume text;')
            db_conn.execute('PRAGMA user_version = {0}'.format(14))

        logger.info('DB version {0}'.format(db_conn.execute('PRAGMA user_version').fetchone()[0]))

        db_conn.close()


def delete_rows(db_conn, table, ids):
    seq = []
    if isinstance(ids[0], dict):
        # Several keys (secondary) are used to delete a row
        sql = 'DELETE FROM {0} WHERE {1}'.format(table, ' AND '.join(f'{skey} = ?' for skey in ids[0].keys()))

        for item in ids:
            seq.append(tuple(item.values()))
    else:
        sql = 'DELETE FROM {0} WHERE id = ?'.format(table)

        for id_ in ids:
            seq.append((id_, ))

    try:
        db_conn.executemany(sql, seq)
    except Exception as e:
        print('SQLite-error:', e, ids)
        return False
    else:
        return True


def insert_row(db_conn, table, data):
    try:
        cursor = db_conn.execute(
            'INSERT INTO {0} ({1}) VALUES ({2})'.format(table, ', '.join(data.keys()), ', '.join(['?'] * len(data))),
            tuple(data.values())
        )
    except Exception as e:
        print('SQLite-error:', e, data)
        return None
    else:
        return cursor.lastrowid


def insert_rows(db_conn, table, data):
    sql = 'INSERT INTO {0} ({1}) VALUES ({2})'.format(table, ', '.join(data[0].keys()), ', '.join(['?'] * len(data[0])))

    seq = []
    for item in data:
        seq.append(tuple(item.values()))

    try:
        db_conn.executemany(sql, seq)
    except Exception as e:
        print('SQLite-error:', e, data)
        return False
    else:
        return True


def update_row(db_conn, table, id_, data):
    try:
        db_conn.execute(
            'UPDATE {0} SET {1} WHERE id = ?'.format(table, ', '.join(k + ' = ?' for k in data)),
            tuple(data.values()) + (id_,)
        )
    except Exception as e:
        print('SQLite-error:', e, data)
        return False
    else:
        return True


def update_rows(db_conn, table, ids, data):
    sql = 'UPDATE {0} SET {1} WHERE id = ?'.format(table, ', '.join(k + ' = ?' for k in data[0]))

    seq = []
    for index, id_ in enumerate(ids):
        seq.append(tuple(data[index].values()) + (id_, ))

    try:
        db_conn.executemany(sql, seq)
    except Exception as e:
        print('SQLite-error:', e, data)
        return False
    else:
        return True
