"""Functions to ease SQL queries management"""

from collections import defaultdict
from datetime import datetime, timedelta, UTC
from functools import cmp_to_key
from hashlib import sha256
from urllib.parse import urljoin, urlparse

import jwt

from fastapi import Request
from sqlalchemy import and_, asc, func, or_, Select
from sqlalchemy.orm import aliased, Session
from sqlalchemy.sql import text as sa_text

from argos import schemas
from argos.logging import logger
from argos.server.models import (
    BlockedToken,
    ConfigCache,
    Job,
    Result,
    Task,
    TaskTmp,
    User,
)
from argos.server.settings import read_config

SEVERITY_LEVELS = {"ok": 1, "warning": 2, "critical": 3, "unknown": 4}


def get_severity_level(severity):
    return SEVERITY_LEVELS.get(severity)


def max_severity(severities):
    return max(severities, key=get_severity_level)


def cmp_severities(a, b):
    if SEVERITY_LEVELS[a] < SEVERITY_LEVELS[b]:
        return 1
    if SEVERITY_LEVELS[a] > SEVERITY_LEVELS[b]:
        return -1
    return 0


def cmp_domains(a, b):
    max_a = max_severity(a[1])
    max_b = max_severity(b[1])
    if max_a > max_b:
        return 1
    if max_a < max_b:
        return -1
    if a[0] > b[0]:
        return 1
    if a[0] < b[0]:
        return -1
    return 0


async def list_tasks(db: Session, agent_id: str, limit: int = 100):
    """List tasks and mark them as selected"""
    # Process tasks which never has been processed first
    subquery = (
        db.query(func.distinct(Task.task_group))
        .filter(
            Task.selected_by == None,  # noqa: E711
            Task.next_run == None,  # noqa: E711
        )
        .limit(limit)
        .subquery()
    )
    tasks = db.query(Task).filter(Task.task_group.in_(Select(subquery))).all()

    if len(tasks):
        now = datetime.now()
        for task in tasks:
            task.selected_at = now
            task.selected_by = agent_id
        db.commit()
        return tasks

    # Now we can process tasks normally
    all_task_groups = (
        db.query(Task.task_group)
        .filter(
            Task.selected_by == None,  # noqa: E711
            Task.next_run <= datetime.now(),  # noqa: E711
        )
        .order_by(asc(Task.next_run))
        .all()
    )
    # We need to do distinct(Task.task_group) in Python
    # since distinct(Task.task_group) is not compatible with
    # an order_by(asc(Task.next_run))
    task_groups: list[str] = []
    for row in all_task_groups:
        if len(task_groups) > limit:
            break
        task_group = row.task_group
        if task_group not in task_groups:
            task_groups.append(task_group)

    tasks = db.query(Task).filter(Task.task_group.in_(task_groups)).all()

    now = datetime.now()
    for task in tasks:
        task.selected_at = now
        task.selected_by = agent_id
    db.commit()
    return tasks


async def add_user(db: Session, name: str, password: str) -> User:
    user = User(
        username=name,
        password=password,
        disabled=False,
    )
    db.add(user)
    db.commit()
    return user


async def get_user(db: Session, username: str) -> None | User:
    return db.get(User, username)


async def list_users(db: Session):
    return db.query(User).order_by(asc(User.username))


async def get_task(db: Session, task_id: int) -> None | Task:
    return db.get(Task, task_id)


async def create_result(db: Session, agent_result: schemas.AgentResult, agent_id: str):
    result = Result(
        submitted_at=datetime.now(),
        status=agent_result.status,
        context=agent_result.context,
        task_id=agent_result.task_id,
        agent_id=agent_id,
    )
    db.add(result)
    return result


async def count_tasks(db: Session, selected: None | bool = None):
    query = db.query(Task)
    if selected is not None:
        if selected:
            query = query.filter(Task.selected_by is not None)  # type: ignore[arg-type]  # ty: ignore[invalid-argument-type]  # pylint: disable=line-too-long
        else:
            query = query.filter(Task.selected_by is None)  # type: ignore[arg-type]  # ty: ignore[invalid-argument-type]  # pylint: disable=line-too-long

    return query.count()


async def count_results(db: Session):
    return db.query(Result).count()


async def has_config_changed(db: Session, config: schemas.Config) -> bool:  # pylint: disable-msg=too-many-statements,too-many-branches
    """Check if websites config has changed by using a hashsum and a config cache"""
    websites_hash = sha256(str(config.websites).encode()).hexdigest()
    conf_caches = db.query(ConfigCache).all()
    same_config = True
    keys = [
        "websites_hash",
        "general_frequency",
        "general_recheck_delay",
        "general_retry_before_notification",
        "general_ipv4",
        "general_ipv6",
    ]
    if conf_caches:
        for conf in conf_caches:
            keys.remove(conf.name)
            match conf.name:
                case "websites_hash":
                    if conf.val != websites_hash:
                        same_config = False
                        conf.val = websites_hash
                        conf.updated_at = datetime.now()
                case "general_frequency":
                    if conf.val != str(config.general.frequency):
                        same_config = False
                        conf.val = str(config.general.frequency)
                        conf.updated_at = datetime.now()
                case "general_recheck_delay":
                    if conf.val != str(config.general.recheck_delay):
                        same_config = False
                        conf.val = str(config.general.recheck_delay)
                        conf.updated_at = datetime.now()
                case "general_retry_before_notification":
                    if conf.val != str(config.general.retry_before_notification):
                        same_config = False
                        conf.val = str(config.general.retry_before_notification)
                        conf.updated_at = datetime.now()
                case "general_ipv4":
                    if conf.val != str(config.general.ipv4):
                        same_config = False
                        conf.val = str(config.general.ipv4)
                        conf.updated_at = datetime.now()
                case "general_ipv6":
                    if conf.val != str(config.general.ipv6):
                        same_config = False
                        conf.val = str(config.general.ipv6)
                        conf.updated_at = datetime.now()

        for i in keys:
            match i:
                case "websites_hash":
                    c = ConfigCache(
                        name="websites_hash",
                        val=websites_hash,
                        updated_at=datetime.now(),
                    )
                case "general_frequency":
                    c = ConfigCache(
                        name="general_frequency",
                        val=str(config.general.frequency),
                        updated_at=datetime.now(),
                    )
                case "general_recheck_delay":
                    c = ConfigCache(
                        name="general_recheck_delay",
                        val=str(config.general.recheck_delay),
                        updated_at=datetime.now(),
                    )
                case "general_retry_before_notification":
                    c = ConfigCache(
                        name="general_retry_before_notification",
                        val=str(config.general.retry_before_notification),
                        updated_at=datetime.now(),
                    )
                case "general_ipv4":
                    c = ConfigCache(
                        name="general_ipv4",
                        val=str(config.general.ipv4),
                        updated_at=datetime.now(),
                    )
                case "general_ipv6":
                    c = ConfigCache(
                        name="general_ipv6",
                        val=str(config.general.ipv6),
                        updated_at=datetime.now(),
                    )
            db.add(c)

        db.commit()

        if keys:
            return True

        if same_config:
            return False

    else:  # no config cache found
        web_hash = ConfigCache(
            name="websites_hash", val=websites_hash, updated_at=datetime.now()
        )
        gen_freq = ConfigCache(
            name="general_frequency",
            val=str(config.general.frequency),
            updated_at=datetime.now(),
        )
        gen_recheck = ConfigCache(
            name="general_recheck_delay",
            val=str(config.general.recheck_delay),
            updated_at=datetime.now(),
        )
        gen_retry_before_notif = ConfigCache(
            name="general_retry_before_notification",
            val=str(config.general.retry_before_notification),
            updated_at=datetime.now(),
        )
        gen_ipv4 = ConfigCache(
            name="general_ipv4",
            val=str(config.general.ipv4),
            updated_at=datetime.now(),
        )
        gen_ipv6 = ConfigCache(
            name="general_ipv6",
            val=str(config.general.ipv6),
            updated_at=datetime.now(),
        )
        db.add(web_hash)
        db.add(gen_freq)
        db.add(gen_recheck)
        db.add(gen_retry_before_notif)
        db.add(gen_ipv4)
        db.add(gen_ipv6)
        db.commit()

    return True


async def update_from_config_later(db: Session, config_file):
    """Ask Argos to reload configuration in a recurring task"""
    jobs = (
        db.query(Job)
        .filter(
            Job.todo == "RELOAD_CONFIG",
            Job.args == config_file,
            Job.current == False,
        )
        .all()
    )
    if jobs:
        return "There is already a config reloading job in the job queue, for the same file"

    job = Job(todo="RELOAD_CONFIG", args=config_file, added_at=datetime.now())
    db.add(job)
    db.commit()

    return "Config reloading has been added in the job queue"


async def process_jobs(db: Session) -> int:
    """Process job queue"""
    jobs = db.query(Job).filter(Job.current == False).all()
    if jobs:
        for job in jobs:
            job.current = True
            db.commit()
            if job.todo == "RELOAD_CONFIG":
                logger.info("Processing job %i: %s %s", job.id, job.todo, job.args)
                _config = read_config(job.args)
                changed = await update_from_config(db, _config)
                logger.info("%i task(s) deleted", changed["deleted"])
                logger.info("%i task(s) added", changed["added"])
                logger.info("%i task(s) updated", changed["updated"])
                db.delete(job)

        db.commit()
        return len(jobs)

    return 0


async def delete_duplicate_tasks(db: Session) -> int:
    """Find duplicate tasks in DB and delete one of them"""

    f_task = aliased(Task)
    s_task = aliased(Task)
    duplicate_tasks = (
        db.query(f_task, s_task)
        .join(
            s_task,
            and_(
                f_task.url == s_task.url,
                f_task.method == s_task.method,
                or_(
                    and_(
                        f_task.request_data == None,
                        s_task.request_data == None,
                    ),
                    f_task.request_data == s_task.request_data,
                ),
                f_task.check == s_task.check,
                f_task.expected == s_task.expected,
                f_task.ip_version == s_task.ip_version,
            ),
        )
        .filter(f_task.id != s_task.id)
        .all()
    )
    deleted_duplicate_tasks = len(duplicate_tasks)
    primary_duplicate_ids: list[int] = []
    for i in duplicate_tasks:
        primary = i[0]
        secondary = i[1]
        if primary.id in primary_duplicate_ids:
            db.delete(secondary)
        elif secondary not in primary_duplicate_ids:
            primary_duplicate_ids.append(primary.id)
            db.delete(secondary)
        else:
            db.delete(primary)

    if deleted_duplicate_tasks:
        db.commit()

    return deleted_duplicate_tasks


async def delete_vanished_tasks(db: Session) -> int:
    """Delete tasks not in temporary config tasks table"""

    tasks_to_delete = (
        db.query(Task)
        .outerjoin(
            TaskTmp,
            and_(
                Task.url == TaskTmp.url,
                Task.method == TaskTmp.method,
                or_(
                    and_(
                        Task.request_data == None,
                        TaskTmp.request_data == None,
                    ),
                    Task.request_data == TaskTmp.request_data,
                ),
                Task.check == TaskTmp.check,
                Task.expected == TaskTmp.expected,
                Task.ip_version == TaskTmp.ip_version,
            ),
        )
        .filter(
            TaskTmp.url == None,
        )
        .all()
    )

    vanished_tasks = len(tasks_to_delete)
    for task in tasks_to_delete:
        logger.debug("Deleting a task from the db: %s", task)
        db.delete(task)

    if vanished_tasks:
        db.commit()

    return vanished_tasks


async def add_tasks_from_config_table(db: Session) -> int:
    """Add tasks from temporary config tasks table"""

    tasks_to_add = (
        db.query(TaskTmp)
        .outerjoin(
            Task,
            and_(
                TaskTmp.url == Task.url,
                TaskTmp.method == Task.method,
                or_(
                    and_(
                        TaskTmp.request_data == None,
                        Task.request_data == None,
                    ),
                    TaskTmp.request_data == Task.request_data,
                ),
                TaskTmp.check == Task.check,
                TaskTmp.expected == Task.expected,
                TaskTmp.ip_version == Task.ip_version,
            ),
        )
        .filter(
            Task.url == None,
        )
        .all()
    )

    tasks = []
    for task_tmp in tasks_to_add:
        task = Task(
            domain=task_tmp.domain,
            url=task_tmp.url,
            ip_version=task_tmp.ip_version,
            method=task_tmp.method,
            request_data=task_tmp.request_data,
            check=task_tmp.check,
            expected=task_tmp.expected,
            frequency=task_tmp.frequency,
            recheck_delay=task_tmp.recheck_delay,
            retry_before_notification=task_tmp.retry_before_notification,
            add_alerts_recipients=task_tmp.add_alerts_recipients,
            overwrite_alerts_recipients=task_tmp.overwrite_alerts_recipients,
            already_retried=False,
        )

        logger.debug("Adding a new task in the db: %s", task)

        tasks.append(task)

    if tasks:
        db.add_all(tasks)
        db.commit()

    return len(tasks)


async def update_tasks(db: Session) -> int:
    """Update tasks from temporary config tasks table"""

    tasks_to_update = (
        db.query(Task, TaskTmp)
        .join(
            TaskTmp,
            and_(
                Task.url == TaskTmp.url,
                Task.method == TaskTmp.method,
                or_(
                    and_(
                        Task.request_data == None,
                        TaskTmp.request_data == None,
                    ),
                    Task.request_data == TaskTmp.request_data,
                ),
                Task.check == TaskTmp.check,
                Task.expected == TaskTmp.expected,
                Task.ip_version == TaskTmp.ip_version,
            ),
        )
        .filter(
            or_(
                Task.frequency != TaskTmp.frequency,
                Task.recheck_delay != TaskTmp.recheck_delay,
                Task.retry_before_notification != TaskTmp.retry_before_notification,
                Task.add_alerts_recipients != TaskTmp.add_alerts_recipients,
                Task.overwrite_alerts_recipients != TaskTmp.overwrite_alerts_recipients,
            )
        )
        .all()
    )
    updated_tasks = len(tasks_to_update)
    for tasks in tasks_to_update:
        task = tasks[0]
        task_tmp = tasks[1]

        logger.debug("Updating task: %s", task)

        task.frequency = task_tmp.frequency
        task.recheck_delay = task_tmp.recheck_delay
        task.retry_before_notification = task_tmp.retry_before_notification
        task.add_alerts_recipients = task_tmp.add_alerts_recipients
        task.overwrite_alerts_recipients = task_tmp.overwrite_alerts_recipients

    if updated_tasks:
        db.commit()

    return updated_tasks


async def update_from_config(db: Session, config: schemas.Config):
    """Update tasks from config file"""

    deleted_duplicate_tasks = await delete_duplicate_tasks(db)

    unique_properties = []
    tmp_tasks: list[TaskTmp] = []

    # Fill the tasks_tmp table
    for website in config.websites:
        domain = str(website.domain)
        frequency = website.frequency or config.general.frequency
        recheck_delay = website.recheck_delay or config.general.recheck_delay
        retry_before_notification = (
            website.retry_before_notification
            if website.retry_before_notification is not None
            else config.general.retry_before_notification
        )
        ipv4 = website.ipv4 if website.ipv4 is not None else config.general.ipv4
        ipv6 = website.ipv6 if website.ipv6 is not None else config.general.ipv6
        if ipv4 is False and ipv6 is False:
            logger.warning("IPv4 AND IPv6 are disabled on website %s!", domain)
            continue

        for ip_version in ["4", "6"]:
            for p in website.paths:
                url = urljoin(domain, str(p.path))
                for check_key, expected in p.checks:
                    if (ip_version == "4" and ipv4 is False) or (
                        ip_version == "6" and ipv6 is False
                    ):
                        continue

                    properties = (
                        url,
                        p.method,
                        p.request_data,
                        check_key,
                        expected,
                        ip_version,
                    )
                    if properties not in unique_properties:
                        unique_properties.append(properties)
                        config_task = TaskTmp(
                            domain=domain,
                            url=url,
                            method=p.method,
                            request_data=p.request_data,
                            check=check_key,
                            expected=expected,
                            ip_version=ip_version,
                            frequency=frequency,
                            recheck_delay=recheck_delay,
                            retry_before_notification=retry_before_notification,
                            add_alerts_recipients=website.add_alerts_recipients,
                            overwrite_alerts_recipients=website.overwrite_alerts_recipients,
                        )
                        tmp_tasks.append(config_task)

    db.add_all(tmp_tasks)
    db.commit()

    vanished_tasks = await delete_vanished_tasks(db)

    added_tasks = await add_tasks_from_config_table(db)

    updated_tasks = await update_tasks(db)

    if str(config.general.db.url).startswith("sqlite"):
        # SQLite has no TRUNCATE instruction
        # See https://www.techonthenet.com/sqlite/truncate.php
        logger.debug("Truncating tasks_tmp table (sqlite)")
        db.query(TaskTmp).delete()
        db.commit()
    else:
        logger.debug("Truncating tasks_tmp table")
        db.execute(sa_text("TRUNCATE TABLE tasks_tmp RESTART IDENTITY;"))
        db.commit()

    return {
        "added": added_tasks,
        "deleted": vanished_tasks + deleted_duplicate_tasks,
        "updated": updated_tasks,
    }


async def get_severity_counts(db: Session) -> dict:
    """Get the severities (ok, warning, critical…) and their count"""
    query = db.query(Task.severity, func.count(Task.id).label("count")).group_by(  # pylint: disable-msg=not-callable
        Task.severity
    )

    # Execute the query and fetch the results
    task_counts_by_severity = query.all()

    counts_dict = dict(task_counts_by_severity)  # type: ignore[var-annotated,arg-type]  # ty: ignore[no-matching-overload]  # pylint: disable=line-too-long
    for key in ("ok", "warning", "critical", "unknown"):
        counts_dict.setdefault(key, 0)
    return counts_dict


async def get_waiting_tasks_count(db: Session) -> int:
    """Get the count of tasks waiting to be processed"""
    waiting_tasks = (
        db.query(Task.id)
        .filter(
            Task.selected_by == None,
            ((Task.next_run <= datetime.now()) | (Task.next_run == None)),
        )
        .count()
    )
    return waiting_tasks


async def reschedule_all(db: Session):
    """Reschedule checks of all non OK tasks ASAP"""
    db.query(Task).filter(Task.severity != "ok").update(
        {Task.next_run: datetime.now() - timedelta(days=1)}
    )
    db.commit()


async def remove_old_results(db: Session, max_results_age: float):
    """Remove old results, based on age"""
    max_acceptable_time = datetime.now() - timedelta(seconds=max_results_age)
    deleted = (
        db.query(Result).filter(Result.submitted_at < max_acceptable_time).delete()
    )
    db.commit()

    return deleted


async def block_token(db: Session, request: Request):
    """Discard user token"""
    manager = request.app.state.manager
    token = await manager._get_token(request)  # pylint: disable-msg=protected-access
    payload = jwt.decode(
        token, manager.secret.secret_for_decode, algorithms=[manager.algorithm]
    )
    blocked_token = BlockedToken(
        token=token, expires_at=datetime.fromtimestamp(payload["exp"], tz=UTC)
    )
    db.add(blocked_token)
    db.commit()


async def remove_old_tokens(db: Session):
    """Remove expired discarded tokens"""
    deleted = (
        db.query(BlockedToken).filter(BlockedToken.expires_at < datetime.now()).delete()
    )
    db.commit()

    return deleted


async def release_old_locks(db: Session, max_lock_seconds: int):
    """Remove outdated locks on tasks"""
    max_acceptable_time = datetime.now() - timedelta(seconds=max_lock_seconds)

    # Release the locks on jobs that have been selected_at for more than max_lock_time
    updated = (
        db.query(Task)
        .filter(Task.selected_at < max_acceptable_time)
        .update({Task.selected_at: None, Task.selected_by: None})
    )
    db.commit()
    return updated


async def get_recent_agents_count(db: Session, minutes: int):
    """Get agents seen less than <minutes> ago"""
    max_time = datetime.now() - timedelta(minutes=minutes)

    agents = db.query(Result.agent_id).filter(Result.submitted_at > max_time).distinct()
    return agents.count()


async def get_domains_infos(
    db: Session, severity: str | None = None, is_not: bool = False
):
    """Get domains, domains’ highest severity and tasks number"""
    if severity is None or severity == "all":
        tasks = db.query(Task).all()
    elif is_not:
        tasks = db.query(Task).filter(Task.severity != severity).all()
    else:
        tasks = db.query(Task).filter(Task.severity == severity).all()

    domains_severities = defaultdict(list)
    domains_last_checks = defaultdict(list)  # type: ignore[var-annotated]

    for task in tasks:
        domain = urlparse(task.url).netloc
        domains_severities[domain].append(task.severity)
        if task.last_severity_update is not None:
            domains_last_checks[domain] = task.last_severity_update
        else:
            domains_last_checks[domain] = "Waiting to be checked"

    domains = [
        (key, sorted(list(set(value)), key=cmp_to_key(cmp_severities), reverse=False))
        for key, value in domains_severities.items()
    ]
    domains.sort(key=cmp_to_key(cmp_domains))

    return {
        "domains": domains,
        "domains_last_checks": domains_last_checks,
        "tasks_nb": len(tasks),
    }


async def last_seen_agents(db: Session):
    return (
        db.query(Result.agent_id, func.max(Result.submitted_at).label("submitted_at"))
        .group_by(Result.agent_id)
        .order_by(Result.agent_id)
        .all()
    )
