"""Checker for duplicate SDK/service client initialization across files.

Detects when the same external SDK class (e.g., Resend, PrismaClient, S3Client)
is instantiated with `new` in multiple files instead of being imported from a
single shared module.

Having duplicate client initialization causes:
- Configuration drift (one copy gets a fix, the other doesn't)
- Multiple connections where one should suffice
- Inconsistent error handling, mocking, or retry logic
"""

import re
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Set, Tuple

from stablestack.checkers.base import BaseChecker
from stablestack.models import Finding, Severity, Tier


class DuplicateClientInitChecker(BaseChecker):
    """
    Checks for the same external SDK client class being instantiated in multiple files.

    Common patterns this catches:
    - `new Resend(...)` in helpers.ts AND ses.ts (should import from one module)
    - `new PrismaClient()` in db.ts AND api.ts (should share singleton)
    - `new S3Client(...)` in uploader.ts AND downloader.ts (should centralize)

    This is a project-level checker that scans all files, then cross-references
    which SDK classes are instantiated in multiple files.
    """

    rule_id = "STRUCT013"
    rule_name = "duplicate-client-init"
    description = "Same SDK client is initialized in multiple files — consolidate into one shared module."
    why = (
        "When the same external service client (like Resend, Stripe, or PrismaClient) is "
        "initialized in multiple files with `new`, changes to one copy don't update the others. "
        "This leads to bugs when copies drift: one file gets a mock mode, retry logic, or config "
        "fix while the others silently keep the old behavior. Centralize initialization in one "
        "shared module and import from there."
    )
    severity = Severity.WARNING
    tier = Tier.RECOMMENDED
    is_project_checker = True
    file_extensions = {".ts", ".tsx", ".js", ".jsx", ".py"}

    # Match: import { ClassName } from "package-name"
    # or: import ClassName from "package-name"
    # Captures: (ClassName, package-name)
    _TS_IMPORT_RE = re.compile(
        r"""import\s+"""
        r"""(?:\{\s*([A-Z][A-Za-z0-9_]*)\s*\}"""  # named: import { Resend }
        r"""|([A-Z][A-Za-z0-9_]*))"""              # default: import Stripe
        r"""\s+from\s+["']([^"'./][^"']*)["']""",  # from "resend" (not relative)
        re.MULTILINE,
    )

    # Match: from package import ClassName  (Python)
    _PY_IMPORT_RE = re.compile(
        r"""from\s+(\S+)\s+import\s+.*?\b([A-Z][A-Za-z0-9_]*)\b""",
        re.MULTILINE,
    )

    # Match: new ClassName(
    _NEW_RE = re.compile(
        r"""\bnew\s+([A-Z][A-Za-z0-9_]*)\s*\(""",
        re.MULTILINE,
    )

    # Match: ClassName( for Python — only after we know it's an imported class
    _PY_CALL_RE = re.compile(
        r"""\b([A-Z][A-Za-z0-9_]*)\s*\(""",
        re.MULTILINE,
    )

    # Built-in classes that should NOT be flagged
    _BUILTIN_CLASSES: Set[str] = {
        "Date", "Error", "Map", "Set", "Array", "Object", "Promise",
        "RegExp", "URL", "URLSearchParams", "Headers", "Request", "Response",
        "FormData", "AbortController", "TextEncoder", "TextDecoder",
        "EventEmitter", "Buffer", "ReadableStream", "WritableStream",
        "TypeError", "RangeError", "SyntaxError", "ReferenceError",
        "Blob", "File", "Uint8Array", "Int32Array", "Float64Array",
        "WeakMap", "WeakSet", "Proxy", "Reflect",
        "TRPCError",  # tRPC — thrown, not a service client
    }

    # Directories to skip
    _SKIP_DIRS = {
        "node_modules", ".git", "__pycache__", ".venv", "venv",
        "dist", "build", ".next", "coverage", ".pytest_cache",
        "e2e", "tests", "__tests__", "test", "scratch",
    }

    def check_file(self, path: Path, content: str) -> List[Finding]:
        """No per-file findings — this is a project-level checker."""
        return []

    def _should_skip(self, path: Path) -> bool:
        """Check if a file should be skipped."""
        for part in path.parts:
            if part in self._SKIP_DIRS:
                return True
        if "test" in path.stem.lower() or "spec" in path.stem.lower():
            return True
        if path.suffix == ".d.ts":
            return True
        return False

    def check_project(self, project_root: Path) -> List[Finding]:
        """Scan all files, then flag SDK classes instantiated in >1 file."""
        # Map: class_name -> [(file_path, line_number, package_name, code_snippet)]
        client_inits: Dict[str, List[Tuple[Path, int, str, str]]] = defaultdict(list)

        # Scan all supported files
        for ext in self.file_extensions:
            for path in project_root.rglob(f"*{ext}"):
                if self._should_skip(path):
                    continue
                try:
                    content = path.read_text(encoding="utf-8", errors="ignore")
                except (OSError, IOError):
                    continue

                lines = content.splitlines()
                if path.suffix == ".py":
                    self._scan_python(path, content, lines, client_inits)
                else:
                    self._scan_ts(path, content, lines, client_inits)

        # Emit findings for classes instantiated in >1 file
        findings: List[Finding] = []

        for class_name, inits in client_inits.items():
            # Group by file
            files_seen: Dict[str, List[Tuple[Path, int, str, str]]] = defaultdict(list)
            for path, line, pkg, code in inits:
                files_seen[str(path)].append((path, line, pkg, code))

            if len(files_seen) < 2:
                continue

            file_list = sorted(files_seen.keys())
            other_files = [str(Path(f).name) for f in file_list]

            for _file_path_str, occurrences in files_seen.items():
                for path, line, pkg, code in occurrences:
                    others = [f for f in other_files if f != path.name]
                    findings.append(
                        self.create_finding(
                            path=path,
                            line=line,
                            column=1,
                            code=code,
                            message=(
                                f"`new {class_name}()` (from \"{pkg}\") is also initialized in: "
                                f"{', '.join(others)}. "
                                f"Consolidate into one shared module to prevent config drift."
                            ),
                            fix_hint=(
                                f"Create a single shared module that initializes {class_name} "
                                f"and export the instance or factory function. "
                                f"Other files should import from there instead of doing "
                                f"`new {class_name}()` independently.\n\n"
                                f"Example:\n"
                                f"  // shared/{class_name.lower()}.ts\n"
                                f"  import {{ {class_name} }} from \"{pkg}\";\n"
                                f"  export const client = new {class_name}(...);\n\n"
                                f"  // other files\n"
                                f"  import {{ client }} from \"./shared/{class_name.lower()}\";\n"
                            ),
                        )
                    )

        return findings

    def _scan_ts(
        self, path: Path, content: str, lines: list,
        client_inits: Dict[str, List[Tuple[Path, int, str, str]]],
    ) -> None:
        """Find external class imports and their `new` instantiations in TS/JS."""
        imported_classes: Dict[str, str] = {}  # class_name -> package_name
        for m in self._TS_IMPORT_RE.finditer(content):
            class_name = m.group(1) or m.group(2)
            package_name = m.group(3)
            if class_name and class_name not in self._BUILTIN_CLASSES:
                imported_classes[class_name] = package_name

        if not imported_classes:
            return

        for m in self._NEW_RE.finditer(content):
            class_name = m.group(1)
            if class_name in imported_classes:
                line_num = content[:m.start()].count("\n") + 1
                line_text = lines[line_num - 1].strip() if line_num <= len(lines) else ""
                if line_text.startswith("//") or line_text.startswith("*"):
                    continue
                if "noqa" in line_text or "stablestack-disable" in line_text:
                    continue
                client_inits[class_name].append(
                    (path, line_num, imported_classes[class_name], line_text[:80])
                )

    def _scan_python(
        self, path: Path, content: str, lines: list,
        client_inits: Dict[str, List[Tuple[Path, int, str, str]]],
    ) -> None:
        """Find external class imports and their instantiations in Python."""
        imported_classes: Dict[str, str] = {}
        for m in self._PY_IMPORT_RE.finditer(content):
            package_name = m.group(1)
            class_name = m.group(2)
            if package_name.startswith(".") or class_name in self._BUILTIN_CLASSES:
                continue
            imported_classes[class_name] = package_name

        if not imported_classes:
            return

        for m in self._PY_CALL_RE.finditer(content):
            class_name = m.group(1)
            if class_name in imported_classes:
                line_num = content[:m.start()].count("\n") + 1
                line_text = lines[line_num - 1].strip() if line_num <= len(lines) else ""
                if line_text.startswith("#"):
                    continue
                if "noqa" in line_text or "stablestack-disable" in line_text:
                    continue
                client_inits[class_name].append(
                    (path, line_num, imported_classes[class_name], line_text[:80])
                )
