"""
Maigret main module
"""

import asyncio
import logging
import os
import sys
import platform
import re
from argparse import ArgumentParser, RawDescriptionHelpFormatter
from typing import Any, Dict, List, Tuple
import os.path as path
from maigret.utils import extract_usernames

try:
    from socid_extractor import extract, parse
except ImportError as e:
    raise ImportError(
        "Missing dependency: socid_extractor\n\n"
        "If installed from PyPI:\n"
        "    pip install -U maigret\n\n"
        "If running from a cloned repository:\n"
        "    pip install -e .\n\n"
        "Then run Maigret as:\n"
        "    python -m maigret <username>"
    ) from e


from .__version__ import __version__
from .checking import (
    timeout_check,
    SUPPORTED_IDS,
    self_check,
    BAD_CHARS,
    maigret,
    build_cloudflare_bypass_config,
)
from . import errors
from .notify import QueryNotifyPrint, print_donate_banner, print_intro_banner
from .report import (
    save_csv_report,
    save_xmind_report,
    save_html_report,
    save_pdf_report,
    generate_report_context,
    save_txt_report,
    SUPPORTED_JSON_REPORT_FORMATS,
    save_json_report,
    get_plaintext_report,
    sort_report_by_data_points,
    save_graph_report,
    save_markdown_report,
)
from .result import SiteResult
from .sites import MaigretDatabase
from .submit import Submitter
from .utils import get_dict_ascii_tree, is_plausible_username
from .settings import Settings
from .permutator import Permute


def extract_ids_from_page(url, logger, timeout=5) -> dict:
    results = {}
    # url, headers
    reqs: List[Tuple[str, set]] = [(url, set())]
    try:
        # temporary workaround for URL mutations MVP
        from socid_extractor import mutate_url

        reqs += list(mutate_url(url))
    except Exception as e:
        logger.warning(e)

    for req in reqs:
        url, headers = req
        print(f'Scanning webpage by URL {url}...')
        page, _ = parse(url, cookies_str='', headers=headers, timeout=timeout)
        logger.debug(page)
        info = extract(page)
        if not info:
            print('Nothing extracted')
        else:
            print(get_dict_ascii_tree(info.items(), new_line=False), ' ')
        for k, v in info.items():

            if k in SUPPORTED_IDS:
                results[v] = k

        for username in extract_usernames(info, logger):
            results[username] = 'username'

    return results


def extract_ids_from_results(results: Dict[str, SiteResult], db: MaigretDatabase) -> dict:
    ids_results = {}
    for website_name in results:
        dictionary = results[website_name]
        new_usernames = dictionary.get('ids_usernames')
        if new_usernames:
            for u, utype in new_usernames.items():
                ids_results[u] = utype

        for url in dictionary.get('ids_links', []):
            ids_results.update(db.extract_ids_from_url(url))

    return ids_results


def setup_arguments_parser(settings: Settings):
    from aiohttp import __version__ as aiohttp_version
    from requests import __version__ as requests_version
    from socid_extractor import __version__ as socid_version

    version_string = '\n'.join(
        [
            f'%(prog)s {__version__}',
            f'Socid-extractor:  {socid_version}',
            f'Aiohttp:  {aiohttp_version}',
            f'Requests:  {requests_version}',
            f'Python:  {platform.python_version()}',
        ]
    )

    parser = ArgumentParser(
        formatter_class=RawDescriptionHelpFormatter,
        description=f"Maigret v{__version__}\n"
        "Documentation: https://maigret.readthedocs.io/\n"
        "All settings are also configurable through files, see docs.",
    )
    parser.add_argument(
        "username",
        nargs='*',
        metavar="USERNAMES",
        help="One or more usernames to search by.",
    )
    parser.add_argument(
        "--version",
        action="version",
        version=version_string,
        help="Display version information and dependencies.",
    )
    parser.add_argument(
        "--timeout",
        action="store",
        metavar='TIMEOUT',
        dest="timeout",
        type=timeout_check,
        default=settings.timeout,
        help="Time in seconds to wait for response to requests "
        f"(default {settings.timeout}s). "
        "A longer timeout will be more likely to get results from slow sites. "
        "On the other hand, this may cause a long delay to gather all results. ",
    )
    parser.add_argument(
        "--retries",
        action="store",
        type=int,
        metavar='RETRIES',
        default=settings.retries_count,
        help="Attempts to restart temporarily failed requests.",
    )
    parser.add_argument(
        "-n",
        "--max-connections",
        action="store",
        type=int,
        dest="connections",
        default=settings.max_connections,
        help=f"Allowed number of concurrent connections (default {settings.max_connections}).",
    )
    parser.add_argument(
        "--dns-resolver",
        dest="dns_resolver",
        default="async",
        choices=("async", "threaded"),
        help=(
            "DNS resolver to use. 'async' (default) uses aiohttp's AsyncResolver "
            "(via aiodns/c-ares) — fastest under high concurrency. 'threaded' uses "
            "the OS getaddrinfo via a threadpool — slower, but respects the system "
            "DNS configuration. Switch to 'threaded' if you see "
            "'Could not contact DNS servers' for every site (issue #2688)."
        ),
    )
    parser.add_argument(
        "--no-recursion",
        action="store_true",
        dest="disable_recursive_search",
        default=(not settings.recursive_search),
        help="Disable recursive search by additional data extracted from pages.",
    )
    parser.add_argument(
        "--no-extracting",
        action="store_true",
        dest="disable_extracting",
        default=(not settings.info_extracting),
        help="Disable parsing pages for additional data and other usernames.",
    )
    parser.add_argument(
        "--id-type",
        dest="id_type",
        default='username',
        choices=SUPPORTED_IDS,
        help="Specify identifier(s) type (default: username).",
    )
    parser.add_argument(
        "--permute",
        action="store_true",
        default=False,
        help="Permute at least 2 usernames to generate more possible usernames.",
    )
    parser.add_argument(
        "--db",
        metavar="DB_FILE",
        dest="db_file",
        default=settings.sites_db_path,
        help="Load Maigret database from a JSON file or HTTP web resource.",
    )
    parser.add_argument(
        "--no-autoupdate",
        action="store_true",
        dest="no_autoupdate",
        default=settings.no_autoupdate,
        help="Disable automatic database updates on startup.",
    )
    parser.add_argument(
        "--force-update",
        action="store_true",
        dest="force_update",
        default=False,
        help="Force check for database updates and download if available.",
    )
    parser.add_argument(
        "--cookies-jar-file",
        metavar="COOKIE_FILE",
        dest="cookie_file",
        default=settings.cookie_jar_file,
        help="File with cookies.",
    )
    parser.add_argument(
        "--ignore-ids",
        action="append",
        metavar='IGNORED_IDS',
        dest="ignore_ids_list",
        default=settings.ignore_ids_list,
        help="Do not make search by the specified username or other ids.",
    )
    # reports options
    parser.add_argument(
        "--folderoutput",
        "-fo",
        dest="folderoutput",
        default=settings.reports_path,
        metavar="PATH",
        help="If using multiple usernames, the output of the results will be saved to this folder.",
    )
    parser.add_argument(
        "--proxy",
        "-p",
        metavar='PROXY_URL',
        action="store",
        dest="proxy",
        default=settings.proxy_url,
        help="Make requests over a proxy. e.g. socks5://127.0.0.1:1080",
    )
    parser.add_argument(
        "--tor-proxy",
        metavar='TOR_PROXY_URL',
        action="store",
        default=settings.tor_proxy_url,
        help="Specify URL of your Tor gateway. Default is socks5://127.0.0.1:9050",
    )
    parser.add_argument(
        "--i2p-proxy",
        metavar='I2P_PROXY_URL',
        action="store",
        default=settings.i2p_proxy_url,
        help="Specify URL of your I2P gateway. Default is http://127.0.0.1:4444",
    )
    parser.add_argument(
        "--with-domains",
        action="store_true",
        default=settings.domain_search,
        help="Enable (experimental) feature of checking domains on usernames.",
    )
    parser.add_argument(
        "--cloudflare-bypass",
        action="store_true",
        default=False,
        help="Enable Cloudflare webgate bypass for sites with protection cf_js_challenge / cf_firewall / webgate. "
             "Requires a local CloudflareBypassForScraping instance (see settings.json -> cloudflare_bypass.modules[0].url).",
    )

    filter_group = parser.add_argument_group(
        'Site filtering', 'Options to set site search scope'
    )
    filter_group.add_argument(
        "-a",
        "--all-sites",
        action="store_true",
        dest="all_sites",
        default=settings.scan_all_sites,
        help="Use all sites for scan.",
    )
    filter_group.add_argument(
        "--top-sites",
        action="store",
        default=settings.top_sites_count,
        metavar="N",
        type=int,
        help="Count of sites for scan ranked by Alexa Top (default: 500).",
    )
    filter_group.add_argument(
        "--tags", dest="tags", default='', help="Specify tags of sites (see `--stats`)."
    )
    filter_group.add_argument(
        "--exclude-tags",
        dest="exclude_tags",
        default='',
        help="Specify tags to exclude from search (blacklist).",
    )
    filter_group.add_argument(
        "--keywords",
        nargs='+',
        metavar='KEYWORD',
        dest="keywords",
        default=[],
        help="Specify keywords to search for in HTML content. Sites containing both username AND any keyword get special highlighting. e.g. --keywords tech python",
    )
    filter_group.add_argument(
        "--site",
        action="append",
        metavar='SITE_NAME',
        dest="site_list",
        default=settings.scan_sites_list,
        help="Limit analysis to just the specified sites (multiple option).",
    )
    filter_group.add_argument(
        "--use-disabled-sites",
        action="store_true",
        default=settings.scan_disabled_sites,
        help="Use disabled sites to search (may cause many false positives).",
    )

    modes_group = parser.add_argument_group(
        'Operating modes',
        'Various functions except the default search by a username. '
        'Modes are executed sequentially in the order of declaration.',
    )
    modes_group.add_argument(
        "--parse",
        dest="parse_url",
        default='',
        metavar='URL',
        help="Parse page by URL and extract username and IDs to use for search.",
    )
    modes_group.add_argument(
        "--submit",
        metavar='URL',
        type=str,
        dest="new_site_to_submit",
        default=False,
        help="URL of existing profile in new site to submit.",
    )
    modes_group.add_argument(
        "--self-check",
        action="store_true",
        default=settings.self_check_enabled,
        help="Do self check for sites and database. Use --auto-disable to disable failing sites.",
    )
    modes_group.add_argument(
        "--auto-disable",
        action="store_true",
        default=False,
        help="With --self-check: automatically disable sites that fail checks.",
    )
    modes_group.add_argument(
        "--diagnose",
        action="store_true",
        default=False,
        help="With --self-check: print detailed diagnosis for each failing site.",
    )
    modes_group.add_argument(
        "--stats",
        action="store_true",
        default=False,
        help="Show database statistics (most frequent sites engines and tags).",
    )
    modes_group.add_argument(
        "--web",
        metavar='PORT',
        type=int,
        nargs='?',  # Optional PORT value
        const=5000,  # Default PORT if `--web` is provided without a value
        default=None,  # Explicitly set default to None
        help="Launch the web interface on the specified port (default: 5000 if no PORT is provided).",
    )
    output_group = parser.add_argument_group(
        'Output options', 'Options to change verbosity and view of the console output'
    )
    output_group.add_argument(
        "--print-not-found",
        action="store_true",
        dest="print_not_found",
        default=settings.print_not_found,
        help="Print sites where the username was not found.",
    )
    output_group.add_argument(
        "--print-errors",
        action="store_true",
        dest="print_check_errors",
        default=settings.print_check_errors,
        help="Print errors messages: connection, captcha, site country ban, etc.",
    )
    output_group.add_argument(
        "--verbose",
        "-v",
        action="store_true",
        dest="verbose",
        default=False,
        help="Display extra information and metrics.",
    )
    output_group.add_argument(
        "--info",
        "-vv",
        action="store_true",
        dest="info",
        default=False,
        help="Display extra/service information and metrics.",
    )
    output_group.add_argument(
        "--debug",
        "-vvv",
        "-d",
        action="store_true",
        dest="debug",
        default=False,
        help="Display extra/service/debug information and metrics, save responses in debug.log.",
    )
    output_group.add_argument(
        "--no-color",
        action="store_true",
        dest="no_color",
        default=(not settings.colored_print),
        help="Don't color terminal output",
    )
    output_group.add_argument(
        "--no-progressbar",
        action="store_true",
        dest="no_progressbar",
        default=(not settings.show_progressbar),
        help="Don't show progressbar.",
    )

    report_group = parser.add_argument_group(
        'Report formats', 'Supported formats of report files'
    )
    report_group.add_argument(
        "-T",
        "--txt",
        action="store_true",
        dest="txt",
        default=settings.txt_report,
        help="Create a TXT report (one report per username).",
    )
    report_group.add_argument(
        "-C",
        "--csv",
        action="store_true",
        dest="csv",
        default=settings.csv_report,
        help="Create a CSV report (one report per username).",
    )
    report_group.add_argument(
        "-H",
        "--html",
        action="store_true",
        dest="html",
        default=settings.html_report,
        help="Create an HTML report file (general report on all usernames).",
    )
    report_group.add_argument(
        "-X",
        "--xmind",
        action="store_true",
        dest="xmind",
        default=settings.xmind_report,
        help="Generate an XMind 8 mindmap report (one report per username).",
    )
    report_group.add_argument(
        "-P",
        "--pdf",
        action="store_true",
        dest="pdf",
        default=settings.pdf_report,
        help="Generate a PDF report (general report on all usernames).",
    )
    report_group.add_argument(
        "-M",
        "--md",
        action="store_true",
        dest="md",
        default=settings.md_report,
        help="Generate a Markdown report (general report on all usernames).",
    )
    report_group.add_argument(
        "-G",
        "--graph",
        action="store_true",
        dest="graph",
        default=settings.graph_report,
        help="Generate a graph report (general report on all usernames).",
    )
    report_group.add_argument(
        "-J",
        "--json",
        action="store",
        metavar='TYPE',
        dest="json",
        default=settings.json_report_type,
        choices=SUPPORTED_JSON_REPORT_FORMATS,
        help=f"Generate a JSON report of specific type: {', '.join(SUPPORTED_JSON_REPORT_FORMATS)}"
        " (one report per username).",
    )

    report_group.add_argument(
        "--ai",
        action="store_true",
        dest="ai",
        default=False,
        help="Generate an AI-powered analysis of the search results using OpenAI API. "
        "Requires OPENAI_API_KEY env var or openai_api_key in settings.",
    )
    report_group.add_argument(
        "--ai-model",
        dest="ai_model",
        default=settings.openai_model,
        help="OpenAI model to use for AI analysis (default: gpt-4o).",
    )

    parser.add_argument(
        "--reports-sorting",
        default=settings.report_sorting,
        choices=('default', 'data'),
        help="Method of results sorting in reports (default: in order of getting the result)",
    )
    return parser


async def main():
    # Logging
    log_level = logging.ERROR
    logging.basicConfig(
        format='[%(filename)s:%(lineno)d] %(levelname)-3s  %(asctime)s %(message)s',
        datefmt='%H:%M:%S',
        level=log_level,
    )
    logger = logging.getLogger('maigret')
    logger.setLevel(log_level)

    # Load settings
    settings = Settings()
    settings_loaded, err = settings.load()

    if not settings_loaded:
        logger.error(err)
        sys.exit(3)

    arg_parser = setup_arguments_parser(settings)
    args = arg_parser.parse_args()

    # Resolve Cloudflare webgate config (CLI flag OR settings.cloudflare_bypass.enabled)
    cf_bypass_config = build_cloudflare_bypass_config(
        settings, force_enable=args.cloudflare_bypass
    )
    if cf_bypass_config:
        modules_summary = ", ".join(
            f"{m.get('name', m.get('method'))}({m.get('url')})"
            for m in cf_bypass_config["modules"]
        )
        logger.info(
            f"Cloudflare webgate active: triggers={cf_bypass_config['trigger_protection']}, "
            f"modules=[{modules_summary}]"
        )

    # Re-set logging level based on args
    if args.debug:
        log_level = logging.DEBUG
    elif args.info:
        log_level = logging.INFO
    elif args.verbose:
        log_level = logging.WARNING
    logger.setLevel(log_level)

    # Usernames initial list
    usernames = {
        u: args.id_type
        for u in args.username
        if u and u not in ['-'] and u not in args.ignore_ids_list
    }
    original_usernames = ""
    if args.permute and len(usernames) > 1 and args.id_type == 'username':
        original_usernames = " ".join(usernames.keys())
        usernames = Permute(usernames).gather(method='strict')

    parsing_enabled = not args.disable_extracting
    recursive_search_enabled = not args.disable_recursive_search

    # Make prompts
    if args.proxy is not None:
        print("Using the proxy: " + args.proxy)

    if args.parse_url:
        extracted_ids = extract_ids_from_page(
            args.parse_url, logger, timeout=args.timeout
        )
        usernames.update(extracted_ids)

    if args.tags:
        args.tags = list(set(str(args.tags).split(',')))

    if args.exclude_tags:
        args.exclude_tags = list(set(str(args.exclude_tags).split(',')))
    else:
        args.exclude_tags = []

    from .db_updater import resolve_db_path, force_update, BUNDLED_DB_PATH

    if args.force_update:
        force_update(
            meta_url=settings.db_update_meta_url,
            color=not args.no_color,
        )

    try:
        db_file = resolve_db_path(
            db_file_arg=args.db_file,
            no_autoupdate=args.no_autoupdate or args.force_update,
            meta_url=settings.db_update_meta_url,
            check_interval_hours=settings.autoupdate_check_interval_hours,
            color=not args.no_color,
        )
    except FileNotFoundError as e:
        logger.error(str(e))
        sys.exit(2)

    if args.top_sites == 0 or args.all_sites:
        args.top_sites = sys.maxsize

    # Create notify object for query results.
    query_notify = QueryNotifyPrint(
        result=None,
        verbose=args.verbose,
        print_found_only=not args.print_not_found,
        skip_check_errors=not args.print_check_errors,
        color=not args.no_color,
        silent=args.ai,
    )

    print_intro_banner(no_color=args.no_color, silent=args.ai)
    print_donate_banner(no_color=args.no_color, silent=args.ai)

    # Create object with all information about sites we are aware of.
    try:
        db = MaigretDatabase().load_from_path(db_file)
        query_notify.success(f'Using sites database: {db_file} ({len(db.sites)} sites)')
    except Exception as e:
        logger.warning(f"Failed to load database from {db_file}: {e}")
        if db_file != BUNDLED_DB_PATH:
            query_notify.warning(
                f'Falling back to bundled database: {BUNDLED_DB_PATH}'
            )
            db = MaigretDatabase().load_from_path(BUNDLED_DB_PATH)
            query_notify.success(
                f'Using sites database: {BUNDLED_DB_PATH} ({len(db.sites)} sites)'
            )
        else:
            raise
    get_top_sites_for_id = lambda x: db.ranked_sites_dict(
        top=args.top_sites,
        tags=args.tags,
        excluded_tags=args.exclude_tags,
        names=args.site_list,
        disabled=args.use_disabled_sites,
        id_type=x,
    )

    site_data = get_top_sites_for_id(args.id_type)

    if args.new_site_to_submit:
        submitter = Submitter(db=db, logger=logger, settings=settings, args=args)
        is_submitted = await submitter.dialog(args.new_site_to_submit, args.cookie_file)
        if is_submitted:
            db.save_to_file(db_file)
        await submitter.close()

    # Database self-checking
    if args.self_check:
        if len(site_data) == 0:
            query_notify.warning(
                'No sites to self-check with the current filters! Exiting...'
            )
            return

        query_notify.success(
            f'Maigret sites database self-check started for {len(site_data)} sites...'
        )
        check_result = await self_check(
            db,
            site_data,
            logger,
            proxy=args.proxy,
            max_connections=args.connections,
            tor_proxy=args.tor_proxy,
            i2p_proxy=args.i2p_proxy,
            auto_disable=args.auto_disable,
            diagnose=args.diagnose,
            no_progressbar=args.no_progressbar,
            cloudflare_bypass=cf_bypass_config,
        )

        is_need_update = check_result.get('needs_update', False)

        if is_need_update:
            if input('Do you want to save changes permanently? [Yn]\n').lower() in (
                'y',
                '',
            ):
                db.save_to_file(db_file)
                print('Database was successfully updated.')
            else:
                print('Updates will be applied only for current search session.')

        if args.verbose or args.debug:
            query_notify.info(
                'Scan sessions flags stats: ' + str(db.get_scan_stats(site_data))
            )

    # Database statistics
    if args.stats:
        print(db.get_db_stats())

    report_dir = path.join(os.getcwd(), args.folderoutput)

    # Make reports folder is not exists
    os.makedirs(report_dir, exist_ok=True)

    # Define one report filename template
    report_filepath_tpl = path.join(report_dir, 'report_{username}{postfix}')

    # Web interface
    if args.web is not None:
        from maigret.web.app import app

        app.config["MAIGRET_DB_FILE"] = db_file

        port = (
            args.web if args.web else 5000
        )  # args.web is either the specified port or 5000 by default

        # Host configuration: secure by default, but allow override via environment
        host = os.getenv('FLASK_HOST', '127.0.0.1')
        app.run(host=host, port=port)
        return

    if usernames == {}:
        # magic params to exit after init
        query_notify.warning('No usernames to check, exiting.')
        sys.exit(0)

    if len(usernames) > 1 and args.permute and args.id_type == 'username':
        query_notify.warning(
            f"{len(usernames)} permutations from {original_usernames} to check..."
            + get_dict_ascii_tree(usernames, prepend="\t")
        )

    if args.ai:
        from .ai import resolve_api_key

        if not resolve_api_key(settings):
            query_notify.warning(
                'AI analysis requires an OpenAI API key. '
                'Set OPENAI_API_KEY environment variable or add '
                'openai_api_key to settings.json.'
            )
            sys.exit(1)

    if not site_data:
        query_notify.warning('No sites to check, exiting!')
        sys.exit(2)

    if args.ai:
        query_notify.warning(
            f'Starting AI-assisted search on top {len(site_data)} sites from the Maigret database...'
        )
    else:
        query_notify.warning(
            f'Starting a search on top {len(site_data)} sites from the Maigret database...'
        )
        if not args.all_sites:
            query_notify.warning(
                'You can run search by full list of sites with flag `-a`', '!'
            )

    already_checked = set()
    general_results = []
    interrupted = False

    # Install our own SIGINT handler. asyncio.run installs a default one
    # that cancels the main task on first Ctrl+C and raises KeyboardInterrupt
    # on the second — but the cancellation gets buried inside the executor's
    # gather/queue cleanup and doesn't bubble up to our `except CancelledError`
    # below until a SECOND press. Owning the signal directly lets us:
    #   1) cancel the in-flight search task on the FIRST press,
    #   2) fall through to report generation,
    #   3) exit hard on a second press if the user changes their mind.
    import signal as _signal
    _interrupt_count = [0]
    _current_search_task: List[Any] = [None]
    _orig_sigint = _signal.getsignal(_signal.SIGINT)

    def _on_sigint(signum, frame):
        _interrupt_count[0] += 1
        task = _current_search_task[0]
        if _interrupt_count[0] == 1 and task is not None and not task.done():
            # First press: cancel the running search. The cancel propagates
            # as CancelledError to `await maigret(...)` below.
            task.cancel()
            return
        # Second press (or no task running): hard exit. Restore the previous
        # handler and re-raise so __main__.py prints "Maigret interrupted."
        # with exit code 130.
        _signal.signal(_signal.SIGINT, _orig_sigint)
        raise KeyboardInterrupt()

    _signal.signal(_signal.SIGINT, _on_sigint)

    while usernames:
        username, id_type = list(usernames.items())[0]
        del usernames[username]

        # First Ctrl+C is caught below and sets `interrupted`. Stop pulling
        # new targets from the queue so we fall through to report generation
        # with whatever has already been collected.
        if interrupted:
            break

        if username.lower() in already_checked:
            continue

        already_checked.add(username.lower())

        if username in args.ignore_ids_list:
            query_notify.warning(
                f'Skip a search by username {username} cause it\'s marked as ignored.'
            )
            continue

        # check for characters do not supported by sites generally
        found_unsupported_chars = set(BAD_CHARS).intersection(set(username))
        if found_unsupported_chars:
            pretty_chars_str = ','.join(
                map(lambda s: f'"{s}"', found_unsupported_chars)
            )
            query_notify.warning(
                f'Found unsupported URL characters: {pretty_chars_str}, skip search by username "{username}"'
            )
            continue

        sites_to_check = get_top_sites_for_id(id_type)

        # Wrap the per-target search in an asyncio.Task so our SIGINT handler
        # can cancel exactly THIS coroutine on first Ctrl+C — not the parent
        # main task, which would also tear down report generation.
        # `partial_results` is the output container that maigret() mutates as
        # site checks complete; on Ctrl+C cancellation it still holds the
        # checks that finished before the cancel, so partial state survives.
        partial_results: Dict[str, SiteResult] = {}
        search_task = asyncio.ensure_future(maigret(
            username=username,
            site_dict=dict(sites_to_check),
            query_notify=query_notify,
            proxy=args.proxy,
            tor_proxy=args.tor_proxy,
            i2p_proxy=args.i2p_proxy,
            timeout=args.timeout,
            is_parsing_enabled=parsing_enabled,
            id_type=id_type,
            debug=args.verbose,
            logger=logger,
            cookies=args.cookie_file,
            forced=args.use_disabled_sites,
            max_connections=args.connections,
            no_progressbar=args.no_progressbar,
            retries=args.retries,
            check_domains=args.with_domains,
            cloudflare_bypass=cf_bypass_config,
            keywords=getattr(args, 'keywords', []),
            dns_resolver=args.dns_resolver,
            output_container=partial_results,
        ))
        _current_search_task[0] = search_task
        try:
            results = await search_task
        except (asyncio.CancelledError, KeyboardInterrupt):
            # First Ctrl+C cancelled `search_task` via our SIGINT handler.
            # Keep whatever partial site checks completed before cancellation
            # so the user gets a meaningful report rather than "0 results".
            interrupted = True
            _current_search_task[0] = None
            results = partial_results
            partial_count = len(partial_results)
            remaining = len(usernames) + 1
            general_results.append((username, id_type, results))
            total_so_far = sum(len(d) for _, _, d in general_results)
            query_notify.warning(
                f'Search interrupted by user (Ctrl+C). Kept {partial_count} '
                f'partial site result(s) for "{username}"; skipping {remaining} '
                f'remaining target(s); generating report from '
                f'{total_so_far} total site result(s) across '
                f'{len(general_results)} username(s). '
                f'Press Ctrl+C again to exit without a report.',
                symbol='!',
            )
            break
        finally:
            _current_search_task[0] = None

        if not args.ai:
            errs = errors.notify_about_errors(
                results, query_notify, show_statistics=args.verbose
            )
            for e in errs:
                query_notify.warning(*e)

        if args.reports_sorting == "data":
            results = sort_report_by_data_points(results)

        general_results.append((username, id_type, results))

        if recursive_search_enabled:
            extracted_ids = extract_ids_from_results(results, db)
            query_notify.warning(f'Extracted IDs: {extracted_ids}')
            usernames.update(extracted_ids)

        # reporting for a one username
        if args.xmind:
            username = username.replace('/', '_')
            filename = report_filepath_tpl.format(username=username, postfix='.xmind')
            save_xmind_report(filename, username, results)
            query_notify.warning(f'XMind report for {username} saved in {filename}')

        if args.csv:
            username = username.replace('/', '_')
            filename = report_filepath_tpl.format(username=username, postfix='.csv')
            save_csv_report(filename, username, results)
            query_notify.warning(f'CSV report for {username} saved in {filename}')

        if args.txt:
            username = username.replace('/', '_')
            filename = report_filepath_tpl.format(username=username, postfix='.txt')
            save_txt_report(filename, username, results)
            query_notify.warning(f'TXT report for {username} saved in {filename}')

        if args.json:
            username = username.replace('/', '_')
            filename = report_filepath_tpl.format(
                username=username, postfix=f'_{args.json}.json'
            )
            save_json_report(filename, username, results, report_type=args.json)
            query_notify.warning(
                f'JSON {args.json} report for {username} saved in {filename}'
            )

    # Restore the original SIGINT handler before report generation. From
    # here on, a Ctrl+C should NOT cancel-and-continue — it should exit.
    _signal.signal(_signal.SIGINT, _orig_sigint)

    # reporting for all the result
    if general_results:
        if interrupted:
            # "Partial results from N username(s)" — disambiguates from the
            # per-site count, which would read confusingly as "N partial
            # results" when N is the username count.
            query_notify.info(
                f'Generating partial results from {len(general_results)} '
                f'username(s) after Ctrl+C...'
            )
        if args.html or args.pdf or args.md:
            query_notify.warning('Generating report info...')
        report_context = generate_report_context(general_results)
        # determine main username
        username = report_context['username']

        if args.html:
            username = username.replace('/', '_')
            filename = report_filepath_tpl.format(
                username=username, postfix='_plain.html'
            )
            save_html_report(filename, report_context)
            query_notify.warning(f'HTML report on all usernames saved in {filename}')

        if args.pdf:
            username = username.replace('/', '_')
            filename = report_filepath_tpl.format(username=username, postfix='.pdf')
            try:
                save_pdf_report(filename, report_context)
            except RuntimeError as e:
                query_notify.warning(str(e))
            else:
                query_notify.warning(
                    f'PDF report on all usernames saved in {filename}'
                )

        if args.md:
            username = username.replace('/', '_')
            filename = report_filepath_tpl.format(username=username, postfix='.md')
            run_flags = []
            if args.tags:
                run_flags.append(f"--tags {args.tags}")
            if args.site_list:
                run_flags.append(f"--site {','.join(args.site_list)}")
            if args.all_sites:
                run_flags.append("--all-sites")
            run_info = {
                "sites_count": sum(len(d) for _, _, d in general_results),
                "flags": " ".join(run_flags) if run_flags else None,
            }
            save_markdown_report(filename, report_context, run_info=run_info)
            query_notify.warning(f'Markdown report on all usernames saved in {filename}')

        if args.graph:
            username = username.replace('/', '_')
            filename = report_filepath_tpl.format(
                username=username, postfix='_graph.html'
            )
            save_graph_report(filename, general_results, db)
            query_notify.warning(f'Graph report on all usernames saved in {filename}')

        if not args.ai:
            text_report = get_plaintext_report(report_context)
            if text_report:
                query_notify.info('Short text report:')
                print(text_report)

        if args.ai:
            from .ai import get_ai_analysis, resolve_api_key
            from .report import generate_markdown_report

            api_key = resolve_api_key(settings)

            run_flags = []
            if args.tags:
                run_flags.append(f"--tags {args.tags}")
            if args.site_list:
                run_flags.append(f"--site {','.join(args.site_list)}")
            if args.all_sites:
                run_flags.append("--all-sites")
            run_info = {
                "sites_count": sum(len(d) for _, _, d in general_results),
                "flags": " ".join(run_flags) if run_flags else None,
            }

            md_report = generate_markdown_report(report_context, run_info=run_info)

            try:
                await get_ai_analysis(
                    api_key=api_key,
                    markdown_report=md_report,
                    model=args.ai_model,
                    api_base_url=getattr(
                        settings, 'openai_api_base_url', 'https://api.openai.com/v1'
                    ),
                )
            except Exception as e:
                query_notify.warning(f'AI analysis failed: {e}')

    # update database
    db.save_to_file(db_file)


def run():
    try:
        if sys.version_info.minor >= 10:
            asyncio.run(main())
        else:
            loop = asyncio.get_event_loop()
            loop.run_until_complete(main())
    except KeyboardInterrupt:
        print('Maigret is interrupted.')
        sys.exit(1)


if __name__ == "__main__":
    run()
