# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (c) 2008-2026 Andrew Ziem.
#
# This work is licensed under the terms of the GNU GPL, version 3 or
# later.  See the COPYING file in the top-level directory.

"""
Cookie module for selective deletion of cookies
"""

import contextlib
import json
import logging
import os
import sqlite3

import bleachbit
from bleachbit import FileUtilities
from bleachbit.Special import sqlite_table_exists

logger = logging.getLogger(__name__)

COOKIE_KEEP_LIST_FILENAME = "cookie_keep_list.json"


def _estimate_in_memory_size(conn, table_name, delete_query, params):
    """Return estimated database size (bytes) after deleting rows in-memory.

    Args:
        conn: SQLite database connection
        table_name (str): Name of the table being modified
        delete_query (str): SQL DELETE query to execute
        params (tuple): Parameters for the DELETE query

    Returns:
        int or None: Estimated size in bytes after deletion, or None if estimation fails
    """
    mem_conn = None
    try:
        mem_conn = sqlite3.connect(':memory:')
        conn.backup(mem_conn)
        mem_cursor = mem_conn.cursor()
        mem_cursor.execute(delete_query, params)
        mem_conn.commit()

        mem_conn.isolation_level = None
        mem_cursor.execute('VACUUM')
        page_count = mem_cursor.execute('PRAGMA page_count').fetchone()[0]
        page_size = mem_cursor.execute('PRAGMA page_size').fetchone()[0]
        return page_count * page_size
    except sqlite3.Error as exc:
        logger.debug(
            'In-memory size estimation failed for %s: %s', table_name, exc)
        return None
    finally:
        if mem_conn:
            mem_conn.close()


def _get_db_disk_size(path):
    """Return total on-disk footprint for a SQLite DB, including WAL/SHM."""
    total = 0
    for suffix in ('', '-wal', '-shm'):
        candidate = f"{path}{suffix}"
        if os.path.exists(candidate):
            try:
                total += FileUtilities.getsize(candidate)
            except OSError as exc:
                logger.debug('Failed to get size for %s: %s', candidate, exc)
    return total


def _checkpoint_wal(conn, path):
    """Checkpoint and truncate WAL to keep disk footprint accurate."""
    prev_isolation = conn.isolation_level
    try:
        conn.isolation_level = None
        conn.execute('PRAGMA wal_checkpoint(TRUNCATE)')
    except sqlite3.Error as exc:
        logger.debug('WAL checkpoint failed for %s: %s', path, exc)
    finally:
        conn.isolation_level = prev_isolation


def _delete_auxiliary_journal_files(path, shred_enabled):
    """Delete SQLite auxiliary files (-wal/-shm) if present."""
    for suffix in ('-wal', '-shm'):
        candidate = f"{path}{suffix}"
        if not os.path.exists(candidate):
            continue
        try:
            FileUtilities.delete(candidate, shred_enabled)
        except OSError as exc:
            logger.debug('Failed to delete %s: %s', candidate, exc)


# SQLite table configurations for different cookie databases
SQLITE_TABLES = {
    'moz_cookies': {
        'table_name': 'moz_cookies',
        'host_column': 'host'
    },
    'cookies': {
        'table_name': 'cookies',
        'host_column': 'host_key'
    }
}


def detect_browser(path):
    """Detect the browser type based on the cookies database file"""
    if not os.path.exists(path):
        raise ValueError(f"cookies file not found: {path}")
    for table_config in SQLITE_TABLES.values():
        if sqlite_table_exists(path, table_config['table_name']):
            return table_config['table_name'], table_config['host_column']
    raise ValueError(f"invalid cookies file: {path}")


def list_cookies(path):
    """List cookies in the database"""
    (table_name, host_column) = detect_browser(path)
    uri = f'file:{path}'
    with contextlib.closing(sqlite3.connect(uri, uri=bool(uri.startswith('file:')))) as conn:
        cursor = conn.cursor()
        cursor.execute(f"SELECT distinct {host_column} FROM {table_name}")
        return cursor.fetchall()


def load_keep_list():
    """Load cookie domains to keep from options directory.

    This does not swallow file permission error or JSON parsing errors:
    when cleaning, these cases should not be treated equally to an empty
    keep list.
    """
    path = os.path.join(bleachbit.options_dir, COOKIE_KEEP_LIST_FILENAME)
    domains = set()
    try:
        with open(path, 'r', encoding='utf-8') as f:
            data = json.load(f)
    except FileNotFoundError:
        return domains

    if isinstance(data, list):
        for item in data:
            if isinstance(item, str) and item:
                domains.add(item.lstrip('.').lower())
    return domains


def delete_cookies(path, keep_list, really_delete=False):
    """Process cookies with optional deletion based on keep list

    Args:
        path (str): Path to the cookies database file
        keep_list (set): Set of hosts to preserve (must not be empty)
        really_delete (bool): If True, perform actual deletion. If False, only preview.

    Returns:
        dict: Results dictionary with deletion statistics

    Raises:
        ValueError: If keep_list is empty
    """
    if not keep_list:
        raise ValueError("keep_list must not be empty")
    assert isinstance(keep_list, set)

    # Find the first matching table configuration
    (table_name, host_column) = detect_browser(path)

    original_size = _get_db_disk_size(path)
    assert original_size > 0

    # Set up connection
    uri = f'file:{path}'
    if not really_delete:
        uri += '?mode=ro'

    from bleachbit.Options import options
    shred_enabled = options.get('shred')

    try:
        with contextlib.closing(sqlite3.connect(uri, uri=bool(uri.startswith('file:')))) as conn:
            cursor = conn.cursor()
            if shred_enabled:
                cursor.execute('PRAGMA secure_delete = ON;')

            # Get total count
            total_before = cursor.execute(
                f"SELECT COUNT(*) FROM {table_name}").fetchone()[0]

            # Build predicate for domain-level keep semantics
            # Match exact domain and any subdomain (both Firefox and Chromium)
            domains = [str(d).lstrip('.').lower() for d in keep_list]
            or_clauses = []
            params = []
            for d in domains:
                or_clauses.append(f"{host_column} = ?")
                params.append(d)
                or_clauses.append(f"{host_column} LIKE ?")
                params.append(f"%.{d}")
            keep_predicate = '(' + ' OR '.join(or_clauses) + ')'

            # Count cookies that will be kept
            kept_count = cursor.execute(
                f"SELECT COUNT(*) FROM {table_name} WHERE {keep_predicate}",
                tuple(params)
            ).fetchone()[0]

            deleted_count = total_before - kept_count
            delete_query = f"DELETE FROM {table_name} WHERE NOT {keep_predicate}"

            ratio_estimate = 0

            if really_delete and deleted_count > 0:
                if kept_count == 0:
                    # No cookies are being kept: delete the whole database file
                    conn.close()
                    if shred_enabled:
                        _delete_auxiliary_journal_files(path, True)
                    try:
                        FileUtilities.delete(path, shred_enabled)
                        return {
                            "total_deleted": deleted_count,
                            "total_kept": 0,
                            "skipped": False,
                            "whole_file_deleted": True,
                            "file_size_reduction": original_size,
                        }
                    except OSError as e:
                        logger.error(
                            "Failed to delete cookie database %s: %s", path, e)
                        return {
                            "total_deleted": 0,
                            "total_kept": 0,
                            "skipped": True,
                            "whole_file_deleted": False,
                            "file_size_reduction": 0,
                        }

                # Perform actual deletion: delete anything NOT matching keep predicate
                cursor.execute(delete_query, tuple(params))
                # Commit deletion before VACUUM
                conn.commit()
                # Run VACUUM in autocommit mode to avoid 'cannot VACUUM from within a transaction'
                prev_isolation = conn.isolation_level
                try:
                    conn.isolation_level = None
                    cursor.execute('VACUUM')
                except sqlite3.Error as e:
                    logger.warning("VACUUM failed on %s: %s", path, e)
                finally:
                    conn.isolation_level = prev_isolation
                if shred_enabled:
                    _checkpoint_wal(conn, path)
                    conn.close()
                    _delete_auxiliary_journal_files(path, True)
                new_size = _get_db_disk_size(path)
                size_reduction = original_size - new_size
                estimation_method = 'actual'
                ratio_estimate = None
                memory_estimate = None
            else:
                # Preview mode or nothing to delete
                if kept_count == 0:
                    # No cookies being kept: entire file would be deleted
                    size_reduction = original_size
                    estimation_method = 'whole_file'
                    ratio_estimate = None
                    memory_estimate = None
                else:
                    if total_before > 0:
                        ratio_estimate = int(
                            (deleted_count / total_before) * original_size)
                    memory_estimate = None
                    if deleted_count > 0 and shred_enabled:
                        # In-memory method is accurate when shredding is enabled
                        memory_size = _estimate_in_memory_size(
                            conn, table_name, delete_query, tuple(params))
                        if memory_size is not None:
                            memory_estimate = max(
                                0, original_size - memory_size)

                    if memory_estimate is not None:
                        size_reduction = memory_estimate
                        estimation_method = 'in_memory'
                    else:
                        size_reduction = ratio_estimate
                        estimation_method = 'ratio'

            return {
                "total_deleted": deleted_count,
                "total_kept": kept_count,
                "skipped": False,
                "whole_file_deleted": False,
                "file_size_reduction": size_reduction,
                "file_size_estimation_method": estimation_method,
                "file_size_reduction_ratio": ratio_estimate,
                "file_size_reduction_in_memory": memory_estimate,
            }

    except sqlite3.Error as e:
        logger.error("SQLite error processing %s: %s", path, e)
        return {
            "total_deleted": 0,
            "total_kept": 0,
            "skipped": True,
            "whole_file_deleted": False,
            "file_size_reduction": 0,
        }


def list_unique_cookies():
    """Return unique cookie hostnames across all cleaners with cookie actions.

    Iterates through every registered cleaner, locates actions whose
    ``command="cookie"`` and aggregates the existing cookie database files
    they target.  Each database is opened using :func:`list_cookies`, and the
    distinct host entries are returned as a sorted list.

    Returns:
        list[str]: Sorted, de-duplicated list of cookie host strings.
    """

    cookie_files = set()
    # Import here to avoid a circular import.
    from bleachbit.Cleaner import backends as cleaner_backends
    for cleaner in cleaner_backends.values():
        actions = getattr(cleaner, 'actions', ())
        for option_id, action in actions:
            if getattr(action, 'action_key', None) != 'cookie':
                continue
            try:
                paths = list(action.get_paths())
            except (OSError, RuntimeError) as exc:
                logger.debug('Unable to enumerate cookie paths for %s.%s: %s',
                             cleaner.get_id(), option_id, exc)
                continue
            for path in paths:
                if path and os.path.isfile(path):
                    cookie_files.add(path)

    unique_hosts = set()
    for path in cookie_files:
        try:
            rows = list_cookies(path)
        except (ValueError, sqlite3.Error, OSError) as exc:
            logger.debug('Skipping cookie database %s: %s', path, exc)
            continue
        for row in rows:
            host = row[0] if isinstance(row, (list, tuple)) else row
            if host:
                unique_hosts.add(str(host))

    return sorted(unique_hosts)
