# local.py
#
# Copyright 2020-2025 Fabio Comuni, et al.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
from typing import Optional

import types
import os
import json
import hashlib
import sqlite3
import importlib.util
import datetime
import time
import logging
from gettext import gettext as _
from dataclasses import dataclass

from gi.repository import GLib

from .settings import Settings
from .fetcher import Fetcher
from . import remotes
from . import models

logger = logging.getLogger(__name__)

# # init and migration machinery

class UnitializedError(Exception):
    pass


class LocalDBDoesNotExistsException(Exception):
    pass


class LocalDBNotOpen(Exception):
    pass


dbfile: Optional[str] = None
db: Optional[sqlite3.Connection] = None
appdir: Optional[str] = None
cachedir: Optional[str] = None


def getDb():
    return db


def init(app_id: str):
    """Init paths and open db"""
    global appdir, cachedir

    appdir = os.path.join(GLib.get_user_data_dir(), app_id)
    if not os.path.exists(appdir):
        os.makedirs(appdir)

    cachedir = os.path.join(GLib.get_user_cache_dir(), app_id)
    if not os.path.exists(cachedir):
        os.makedirs(cachedir)

    Settings.init(app_id)


def import_migration(filename: str) -> types.ModuleType:
    modulename = os.path.basename(filename).replace(".py", "")
    spec = importlib.util.spec_from_file_location(modulename, filename)
    if spec is None or spec.loader is None:
        raise ImportError(f"Unable to import migration {filename}")
    migration = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(migration)
    return migration


def migrate():
    schema = -1
    migrated = False
    try:
        c = db.execute("SELECT value FROM meta WHERE key='schema'")
        info = c.fetchone()
        if info is not None:
            schema = int(info['value'])
    except Exception as e:
        logger.error(f"migrate(): {e}")

    migrationsdir = os.path.join(os.path.dirname(__file__), "migrations")
    files = sorted([f for f in os.listdir(migrationsdir) if f.endswith(".py")])

    try:
        c = db.cursor()
        for f in files:
            newschema = int(f.split("_")[0])
            if newschema > schema:
                # TODO: how to log using glib?
                logger.info("Migrating to {}".format(f.replace(".py", "")))
                import_migration(os.path.join(migrationsdir, f)).up(c)
                schema = newschema
                migrated = True
    except Exception as e:
        db.rollback()
        raise e from None

    db.execute("INSERT OR REPLACE INTO meta (key, value) VALUES ('schema', ?)", (schema,))
    db.commit()

    return migrated

# event database

@dataclass
class OpenResult:
    """
        Result from "openconf" and "opendb", collect info on what's the status of
        the conference to open to che caller
    """
    fetcher: Fetcher|None
    is_new: bool
    is_outdated: bool


def openconf(conf: models.Conference, is_online=True) -> OpenResult:
    """Open event database for `conf`

    conf is a models.Conference
    After opening, tries to update meta
    """
    logger.info(f"open conf {conf=} {is_online=}")
    def _update_meta(*args):
        from .models import Meta
        with Meta() as m:
            m.title = m.title if m.title else conf.title
            m.start = m.start if m.start else conf.start
            m.end = m.end if m.end else conf.end

    res = opendb(conf.url, is_online)
    if res.fetcher is None:
        _update_meta()
    else:
        res.fetcher.connect("done", _update_meta)

    logger.info(f"open conf {res=}")

    return res

def get_dbfilename(url):
    dbhashname = hashlib.md5(url.encode('utf8')).hexdigest()
    return os.path.join(cachedir, dbhashname + ".db")

def existsdb(url):
    """Check if exists database for `url`"""
    dbfilename = get_dbfilename(url)
    return os.path.exists(dbfilename)


def opendb(url, is_online=True) -> OpenResult:
    """Open event database for `url`

    if `is_online`, fetch original pentabarf and update cache

    return Fetcher instance if downloading, else None
    """
    global dbfile, db

    if not existsdb(url) and not is_online:
        db = None
        raise LocalDBDoesNotExistsException(_("Device disconnected and no cached data available."))

    dbfilename = get_dbfilename(url)
    logger.info(f"open db '{dbfilename}'")
    db = sqlite3.connect(dbfilename, detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES)
    db.row_factory = sqlite3.Row

    migrated = migrate()

    dbfile = dbfilename

    is_outdated = check_is_outdated()
    res = OpenResult(fetcher=None, is_new=migrated, is_outdated=is_outdated)

    need_update = check_need_update(is_online, migrated)
    logger.debug(f"{need_update=} {is_online=} {migrated=} {is_outdated=}")
    if need_update:
        res.fetcher = updatedb(url)

    logger.info(f"open db {res=}")
    return res

def check_is_outdated():
    return (time.time() - models.Meta().last_update) > Settings.instance().get_event_cache()

def check_need_update(is_online=True, migrated=False):
    is_time_to_update = check_is_outdated()
    return is_online and (migrated or is_time_to_update)


def updatedb(from_url) -> Fetcher:
    """Update event cache from schedule url

    return Fetcher instance
    """
    if db is None:
        raise LocalDBNotOpen()
    return remotes.update_schedule(from_url)


def close():
    """Close db"""
    global db, dbfile
    dbfile = None
    if db is not None:
        db.close()
        db = None


# # schedules list (from giggity and user defined)


def update_menu() -> Fetcher:
    """Fetch remote menu json"""
    # TODO: what about user entries shadowing entries from giggity?
    if cachedir is None:
        raise UnitializedError("cachedir is not defiend, local db not initialized")
    ggcachefile = os.path.join(cachedir, "ggmenu.json")
    url = "https://ggt.gaa.st/menu.json"
    return Fetcher(url, ggcachefile)


def open_menu(is_online) -> Fetcher|None:
    """
    Update menu cache file if expired or not exists.
    returns None or Fetcher
    """
    ggcachefile = os.path.join(cachedir, "ggmenu.json")
    logger.info(f"menu cache file: '{ggcachefile}'")

    need_refresh = not os.path.exists(ggcachefile)
    need_refresh = need_refresh or ((time.time() - os.path.getmtime(ggcachefile)) > Settings.instance().get_list_cache())
    if need_refresh and is_online:
        return update_menu()

    return None


def get_menu():
    """
    Get menu entries from menu cache and from user menu entries.
    User entries with same urls as entries from gg cache file shadow
    the latter (happens when user edit an entry from gg)
    """
    ggcachefile = os.path.join(cachedir, "ggmenu.json")
    userfile = os.path.join(cachedir, "usermenu.json")

    schedules = []
    if os.path.exists(userfile):
        with open(userfile, "r") as f:
            schedules += json.load(f)
    userurls = [s['url'] for s in schedules]

    if os.path.exists(ggcachefile):
        with open(ggcachefile, "r") as f:
            menu = json.load(f)
        for s in menu['schedules']:
            if s['url'] not in userurls:
                schedules.append(s)

    def _sort_key(elm):
        start = elm['start']
        return datetime.date(*[int(s) for s in start.split("-")])

    schedules = sorted(schedules, key=_sort_key, reverse=True)
    return schedules


def _add_user_menu_ready(sender, local_file_path, cbk):
    userfile = os.path.join(cachedir, "usermenu.json")
    from .models import Meta
    m = Meta()
    item = m.to_json()

    if m.title is None:
        m.title = ""
        m.save()

    schedules = []
    if os.path.exists(userfile):
        with open(userfile, "r") as f:
            schedules = json.load(f)

    schedules.append(item)
    with open(userfile, "w") as f:
        json.dump(schedules, f)

    cbk(m)
    close()


class MenuItemAlreadyExistsException(Exception):
    def __init__(self, message, obj):
        super().__init__(message)
        self.obj = obj


def add_user_menu(url, cbk):
    """
    Add and fetch schedule from url.
    Returns Fetcher or None.
    When ready calls "cbk({..metadata..})"
    """
    # try to not add same event twice:
    objs = [e for e in get_menu() if e['url'] == url]
    if len(objs) > 0:
        raise MenuItemAlreadyExistsException(_("Event already in list"), objs[0])

    f = opendb(url, True)  # I suppose that we are online at this point..
    if f is not None:
        f.connect("done", _add_user_menu_ready, cbk)
    else:
        _add_user_menu_ready(None, None, cbk)

    return f


def update_user_menu(data):
    """
    Update user menu entry
    data is a dict()
    """
    data['user'] = True
    userfile = os.path.join(cachedir, "usermenu.json")
    if os.path.exists(userfile):
        with open(userfile, "r") as f:
            schedules = json.load(f)
    else:
        schedules = []

    _id = None
    for i, s in enumerate(schedules):
        if s['url'] == data['url']:
            _id = i
            break
    if _id is None:
        schedules.append(data)
    else:
        schedules[_id] = data

    with open(userfile, "w") as f:
        json.dump(schedules, f)


def delete_user_menu(data):
    """
    Remove user menu entry
    `data` is a dict()
    """
    userfile = os.path.join(cachedir, "usermenu.json")
    if not os.path.exists(userfile):
        # non dovrei arrivare qui. solo i meta con user=True
        # posso essere editati, e quindi deve esistere il file
        raise Exception("User menu file not found. This should not happen.")

    with open(userfile, "r") as f:
        schedules = json.load(f)

    _id = None
    for i, s in enumerate(schedules):
        if s['url'] == data['url']:
            _id = i
            break
    if _id is not None:
        del schedules[_id]

    with open(userfile, "w") as f:
        json.dump(schedules, f)


# async image fetcher
def get_image_async(fileurl, cbk):
    """
    Save a remote image in local cache.
    Returns Fetcher or None
    When ready calls "cbk(None, 'localfilename')"
    """
    if fileurl is None:
        return None
    localfilename = hashlib.md5(fileurl.encode('utf8')).hexdigest()
    localdir = os.path.join(cachedir, "images")
    if not os.path.exists(localdir):
        os.makedirs(localdir)
    localfile = os.path.join(localdir, localfilename)
    if not os.path.exists(localfile):
        try:
            return Fetcher(fileurl, localfile, cbk)
        except Exception:
            return None
    cbk(None, localfile)
    return None


# cache handling
def get_cache_size():
    """get total size of cache files: db and images.
    skip json menu files"""
    total_size = 0
    for dirpath, dirnames, filenames in os.walk(cachedir):
        for f in filenames:
            if not f.endswith(".json"):
                fp = os.path.join(dirpath, f)
                # skip if it is symbolic link
                if not os.path.islink(fp):
                    total_size += os.path.getsize(fp)
    return total_size


def clear_cache():
    """remove all files in cachedir except currently opened database and json menu files"""
    for dirpath, dirnames, filenames in os.walk(cachedir):
        for f in filenames:
            if not f.endswith(".json"):
                fp = os.path.join(dirpath, f)
                if fp != dbfile:
                    os.remove(fp)

