import asyncio
import base64
import logging
import re
import sys
import time
from collections.abc import Callable
from datetime import datetime
from pathlib import Path

import httpx

from auditor.core.base import Validator
from auditor.core.factory import ValidatorFactory

logger = logging.getLogger(__name__)


class AsyncKeyAuditor:
    """
    Asynchronous engine for scanning GitHub repositories for leaked API keys.

    **v2.0 changes:**
    - Provider-scoped deduplication cache (``tested_pairs``) using
      ``"provider:key"`` tuples to prevent cross-provider collisions.
    - Startup handshake with ``GET /rate_limit`` to verify token
      validity and available quota before sending any search requests.
    - Fail-fast pattern: if quota is exhausted, the process exits
      cleanly with actionable information instead of sleeping blindly.
    - Result lines include ISO timestamps.

    Attributes:
        github_token: Personal access token for GitHub API
        max_concurrent: Maximum number of concurrent requests
        tested_pairs: Provider-scoped deduplication cache

    Example:
        auditor = AsyncKeyAuditor("ghp_xxx", max_concurrent=5)
        await auditor.run(
            query="API_KEY",
            pages=5,
            output_file="results.txt"
        )
    """

    GITHUB_API_BASE = "https://api.github.com"
    REQUEST_DELAY = 0.5  # Seconds between requests for rate limiting

    def __init__(
        self,
        github_token: str,
        max_concurrent: int = 5,
        on_key_found: Callable[[str, str, bool], None] | None = None,
        on_page_start: Callable[[int], None] | None = None,
    ):
        """
        Initialize the auditor.

        Args:
            github_token: GitHub Personal Access Token
            max_concurrent: Maximum concurrent requests (default: 5)
            on_key_found: Optional callback when a key is found
                         Signature: (provider, key, is_valid) -> None
            on_page_start: Optional callback when processing a page starts
                          Signature: (page_number) -> None
        """
        self.headers = {
            "Authorization": f"token {github_token}",
            "Accept": "application/vnd.github.v3+json",
            "User-Agent": "LLMLeaks/2.0",
        }
        self.tested_pairs: set[str] = set()
        self.semaphore = asyncio.Semaphore(max_concurrent)
        self.on_key_found = on_key_found
        self.on_page_start = on_page_start
        self._valid_count = 0
        self._scanned_count = 0

    @property
    def stats(self) -> dict:
        """Get current scan statistics."""
        return {
            "scanned": self._scanned_count,
            "valid": self._valid_count,
            "unique_keys": len(self.tested_pairs),
        }

    # ------------------------------------------------------------------
    # Fail-Fast Quota Management
    # ------------------------------------------------------------------

    def _trigger_fail_fast(self, reset_ts: float) -> None:
        """Terminate cleanly when GitHub quota is exhausted.

        Prints actionable instructions (exact reset time, link to
        generate a new token) and calls ``sys.exit(0)`` to free the
        terminal instead of blocking with a blind sleep.
        """
        now = time.time()
        wait_seconds = int(reset_ts - now) + 5
        resume_time = datetime.fromtimestamp(reset_ts).strftime("%H:%M:%S")

        print(
            "\n\n\033[91m" + "=" * 55
            + "\n QUOTA EXHAUSTED"
            + "\n" + "=" * 55 + "\033[0m"
        )
        print(
            "\033[93m[!] All credits for the current token have been consumed.\033[0m"
        )
        print("\nYou have two options:")
        print(
            f"  \033[96m1. Wait:\033[0m GitHub will reset at"
            f" {resume_time} (in ~{wait_seconds}s)."
        )
        print(
            "  \033[96m2. Rotate token:\033[0m Generate a new one at"
            " \033[4mhttps://github.com/settings/tokens\033[0m"
            " and relaunch."
        )
        print(
            "\n\033[94m[*] Process terminated to release resources."
            " Happy hunting!\033[0m\n"
        )
        sys.exit(0)

    async def startup_handshake(self, client: httpx.AsyncClient) -> None:
        """Pre-flight check: verify token validity and available quota.

        Makes a single ``GET /rate_limit`` request before any scan work.
        If the token is invalid or the remaining quota is too low to be
        useful, the process exits immediately with clear feedback.
        """
        logger.debug("Running startup handshake against /rate_limit")
        try:
            res = await client.get(f"{self.GITHUB_API_BASE}/rate_limit")

            if res.status_code == 401:
                print(
                    "\033[91m[!] CRITICAL: The provided GitHub token"
                    " is invalid.\033[0m"
                )
                sys.exit(1)

            res.raise_for_status()
            data = res.json()["resources"]

            core_remaining = data["core"]["remaining"]
            search_remaining = data["search"]["remaining"]

            logger.debug(
                "Quota -- core: %d/5000, search: %d/30",
                core_remaining,
                search_remaining,
            )

            if core_remaining < 20 or search_remaining < 1:
                reset_ts = max(
                    data["core"]["reset"],
                    data["search"]["reset"],
                )
                self._trigger_fail_fast(reset_ts)

        except SystemExit:
            raise
        except Exception as exc:
            print(
                f"\033[91m[!] Connection error during handshake: {exc}\033[0m"
            )
            sys.exit(1)

    async def check_quotas_inline(self, client: httpx.AsyncClient) -> bool:
        """Mid-scan quota check; triggers fail-fast if depleted."""
        try:
            res = await client.get(f"{self.GITHUB_API_BASE}/rate_limit")
            res.raise_for_status()
            data = res.json()["resources"]

            if data["core"]["remaining"] < 20 or data["search"]["remaining"] < 1:
                reset_ts = max(
                    data["core"]["reset"],
                    data["search"]["reset"],
                )
                self._trigger_fail_fast(reset_ts)
            return True
        except SystemExit:
            raise
        except Exception:
            return False

    # ------------------------------------------------------------------
    # HTTP helpers
    # ------------------------------------------------------------------

    async def _safe_request(
        self,
        client: httpx.AsyncClient,
        url: str,
    ) -> httpx.Response | None:
        """
        Make a rate-limited HTTP request via the GitHub API.

        Args:
            client: The HTTP client to use
            url: The URL to request

        Returns:
            Response object or None on error
        """
        async with self.semaphore:
            try:
                await asyncio.sleep(self.REQUEST_DELAY)
                res = await client.get(url)
                if res.status_code == 403:
                    return res
                res.raise_for_status()
                return res
            except httpx.HTTPError as e:
                logger.debug("Request error for %s: %s", url, e)
                return None

    # ------------------------------------------------------------------
    # File processing
    # ------------------------------------------------------------------

    async def process_file(
        self,
        client: httpx.AsyncClient,
        file_url: str,
        output_file: str,
    ) -> None:
        """
        Process a single file from GitHub and scan for API keys.

        Args:
            client: HTTP client instance
            file_url: GitHub API URL for the file
            output_file: Path to write valid keys
        """
        response = await self._safe_request(client, file_url)

        if not response or response.status_code != 200:
            return

        try:
            raw_content = response.json().get("content", "")
            content = base64.b64decode(raw_content).decode(
                "utf-8", errors="ignore"
            )
            self._scanned_count += 1

            # Collect tests to run (provider-scoped dedup)
            tests_to_run = []
            for provider, (pattern, validator) in (
                ValidatorFactory.get_config().items()
            ):
                for key in re.findall(pattern, content):
                    cache_id = f"{provider}:{key}"
                    if cache_id not in self.tested_pairs:
                        self.tested_pairs.add(cache_id)
                        tests_to_run.append((provider, key, validator))

            for provider, key, validator in tests_to_run:
                await self._process_key(
                    client, provider, key, validator, output_file
                )

        except (ValueError, KeyError) as e:
            logger.debug("Error processing file %s: %s", file_url, e)

    async def _process_key(
        self,
        client: httpx.AsyncClient,
        provider: str,
        key: str,
        validator: Validator,
        output_file: str,
    ) -> None:
        """Validate and record a single discovered key."""
        is_valid = await validator.validate(client, key)

        if self.on_key_found:
            self.on_key_found(provider, key, is_valid)

        if is_valid:
            self._valid_count += 1
            logger.debug("[VALID] %s: %s...", provider.upper(), key[:15])
            self._write_result(output_file, provider, key)
        else:
            logger.debug("[INVALID] %s: %s...", provider.upper(), key[:10])

    def _write_result(
        self, output_file: str, provider: str, key: str
    ) -> None:
        """Write a valid key to the output file with a timestamp."""
        output_path = Path(output_file)
        output_path.parent.mkdir(parents=True, exist_ok=True)

        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
        with open(output_path, "a", encoding="utf-8") as f:
            f.write(f"[{timestamp}] [{provider.upper()}] {key}\n")

    # ------------------------------------------------------------------
    # Main scan loop
    # ------------------------------------------------------------------

    async def run(
        self,
        query: str,
        pages: int,
        output_file: str,
        per_page: int = 50,
    ) -> dict:
        """
        Execute the audit scan.

        Args:
            query: GitHub code search query
            pages: Number of result pages to process
            output_file: Path for writing valid keys
            per_page: Results per page (max 100)

        Returns:
            Dictionary containing scan statistics
        """
        async with httpx.AsyncClient(
            headers=self.headers, timeout=25.0
        ) as client:
            await self.startup_handshake(client)

            logger.debug("Starting scan for query: '%s'", query)

            for page in range(1, pages + 1):
                await self.check_quotas_inline(client)

                if self.on_page_start:
                    self.on_page_start(page)
                logger.debug("Processing page %d/%d", page, pages)

                search_url = (
                    f"{self.GITHUB_API_BASE}/search/code"
                    f"?q={query}&page={page}&per_page={per_page}"
                )

                response = await self._safe_request(client, search_url)

                if response and response.status_code == 403:
                    await self.check_quotas_inline(client)
                    continue

                if not response:
                    logger.debug("No response for page %d", page)
                    continue

                items = response.json().get("items", [])
                if not items:
                    logger.debug("No more results found")
                    break

                tasks = [
                    self.process_file(client, item["url"], output_file)
                    for item in items
                ]
                await asyncio.gather(*tasks)

            logger.debug(
                "Scan complete. Found %d valid keys out of %d tested pairs",
                self._valid_count,
                len(self.tested_pairs),
            )

            return self.stats
