# retoor <retoor@molodetz.nl>
from __future__ import annotations

import json
import logging
import re
from dataclasses import dataclass
from pathlib import Path

from devplacepy.services.jobs.isslop.analysis.baselines import RepoBaselines, compute_baselines, deviation_signals
from devplacepy.services.jobs.isslop.analysis.exclusion import exclusion_reason, is_excluded_directory, looks_minified
from devplacepy.services.jobs.isslop.analysis.languages import CODE_LANGUAGES, DOCUMENT_LANGUAGES, extract_comments, language_for
from devplacepy.services.jobs.isslop.analysis.metrics import FileMetrics, compute_metrics
from devplacepy.services.jobs.isslop.analysis.scoring import (
    FileScore,
    categorize,
    criticality_for,
    fingerprint_origin_score,
    origin_score,
    quality_deficit_score,
)
from devplacepy.services.jobs.isslop.analysis.signals import Detector, FileContext, RepoContext, run_detectors
from devplacepy.services.jobs.isslop.analysis.signals.documentation import detect_documentation
from devplacepy.services.jobs.isslop.analysis.signals.errors import detect_error_handling
from devplacepy.services.jobs.isslop.analysis.signals.hallucination import detect_hallucination
from devplacepy.services.jobs.isslop.analysis.signals.infrastructure import detect_infrastructure
from devplacepy.services.jobs.isslop.analysis.signals.language import detect_language_tells
from devplacepy.services.jobs.isslop.analysis.signals.llmdefaults import detect_llm_defaults
from devplacepy.services.jobs.isslop.analysis.signals.naming import detect_naming
from devplacepy.services.jobs.isslop.analysis.signals.scaffolds import detect_scaffolds
from devplacepy.services.jobs.isslop.analysis.signals.security import detect_security
from devplacepy.services.jobs.isslop.analysis.signals.structure import detect_structure
from devplacepy.services.jobs.isslop.analysis.signals.textual import detect_textual
from devplacepy.services.jobs.isslop.analysis.signals.vibeerrors import detect_vibe_errors
from devplacepy.services.jobs.isslop.analysis.signals.webtells import detect_web_tells
from devplacepy.services.jobs.isslop.config import ANALYSIS_MAX_FILES

logger = logging.getLogger(__name__)

DETECTORS: list[Detector] = [
    detect_textual,
    detect_naming,
    detect_structure,
    detect_hallucination,
    detect_error_handling,
    detect_security,
    detect_documentation,
    detect_language_tells,
    detect_web_tells,
    detect_llm_defaults,
    detect_infrastructure,
    detect_vibe_errors,
    detect_scaffolds,
]

FINGERPRINT_DETECTORS: list[Detector] = [
    detect_textual,
    detect_web_tells,
    detect_llm_defaults,
    detect_infrastructure,
    detect_security,
    detect_vibe_errors,
    detect_scaffolds,
]

PYPROJECT_DEPENDENCY_PATTERN = re.compile(r"[\"']([A-Za-z0-9._-]+)\s*(?:[><=!~\[;].*)?[\"']")


@dataclass(frozen=True)
class InventoryEntry:
    path: Path
    relative: str
    language: str


@dataclass(frozen=True)
class Inventory:
    analyzable: list[InventoryEntry]
    excluded: list[tuple[str, str]]
    repo: RepoContext


def _python_dependencies(root: Path) -> tuple[frozenset[str], bool]:
    dependencies: set[str] = set()
    found = False
    for name in ("requirements.txt", "requirements-dev.txt"):
        manifest = root / name
        if manifest.exists():
            found = True
            for line in manifest.read_text(encoding="utf-8", errors="replace").splitlines():
                cleaned = line.split("#")[0].strip()
                if cleaned and not cleaned.startswith("-"):
                    dependencies.add(re.split(r"[><=!~\[;@ ]", cleaned)[0])
    pyproject = root / "pyproject.toml"
    if pyproject.exists():
        text = pyproject.read_text(encoding="utf-8", errors="replace")
        if "dependencies" in text or "[tool.poetry" in text:
            found = True
        block = re.search(r"dependencies\s*=\s*\[(.*?)\]", text, re.DOTALL)
        if block:
            for match in PYPROJECT_DEPENDENCY_PATTERN.finditer(block.group(1)):
                dependencies.add(match.group(1))
        poetry = re.search(r"\[tool\.poetry\.dependencies\](.*?)(?:\n\[|\Z)", text, re.DOTALL)
        if poetry:
            for line in poetry.group(1).splitlines():
                key = line.split("=")[0].strip().strip('"')
                if key and key != "python":
                    dependencies.add(key)
        project_name = re.search(r"^\s*name\s*=\s*[\"']([\w.-]+)[\"']", text, re.MULTILINE)
        if project_name:
            dependencies.add(project_name.group(1))
    return frozenset(dependencies), found


def _javascript_dependencies(root: Path) -> tuple[frozenset[str], bool]:
    manifest = root / "package.json"
    if not manifest.exists():
        return frozenset(), False
    try:
        body = json.loads(manifest.read_text(encoding="utf-8", errors="replace"))
    except (json.JSONDecodeError, OSError) as error:
        logger.warning("package.json unreadable: %s", error)
        return frozenset(), False
    dependencies: set[str] = set()
    for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
        section = body.get(key)
        if isinstance(section, dict):
            dependencies.update(section.keys())
    name = body.get("name")
    if isinstance(name, str):
        dependencies.add(name)
    return frozenset(dependencies), True


def _local_python_modules(root: Path) -> frozenset[str]:
    modules: set[str] = set()
    for entry in root.iterdir():
        if entry.is_dir() and not is_excluded_directory(entry.name):
            modules.add(entry.name)
            if (entry / "__init__.py").exists():
                modules.add(entry.name)
        elif entry.suffix == ".py":
            modules.add(entry.stem)
    for src in (root / "src", root / "lib"):
        if src.is_dir():
            for entry in src.iterdir():
                if entry.is_dir() or entry.suffix == ".py":
                    modules.add(entry.stem if entry.is_file() else entry.name)
    return frozenset(modules)


JSONC_TRAILING_COMMA_PATTERN = re.compile(r",(\s*[}\]])")


def _strip_jsonc_comments(text: str) -> str:
    out: list[str] = []
    position = 0
    length = len(text)
    in_string = False
    escaped = False
    while position < length:
        char = text[position]
        if in_string:
            out.append(char)
            if escaped:
                escaped = False
            elif char == "\\":
                escaped = True
            elif char == '"':
                in_string = False
            position += 1
            continue
        if char == '"':
            in_string = True
            out.append(char)
            position += 1
            continue
        if char == "/" and text[position + 1 : position + 2] == "/":
            while position < length and text[position] != "\n":
                position += 1
            continue
        if char == "/" and text[position + 1 : position + 2] == "*":
            position += 2
            while position + 1 < length and not (text[position] == "*" and text[position + 1] == "/"):
                position += 1
            position += 2
            continue
        out.append(char)
        position += 1
    return "".join(out)


def _parse_jsonc(text: str) -> dict:
    cleaned = _strip_jsonc_comments(text)
    cleaned = JSONC_TRAILING_COMMA_PATTERN.sub(r"\1", cleaned)
    parsed = json.loads(cleaned)
    return parsed if isinstance(parsed, dict) else {}


def _javascript_alias_prefixes(root: Path) -> frozenset[str]:
    prefixes: set[str] = set()
    for name in ("tsconfig.json", "jsconfig.json"):
        manifest = root / name
        if not manifest.exists():
            continue
        try:
            body = _parse_jsonc(manifest.read_text(encoding="utf-8", errors="replace"))
        except (json.JSONDecodeError, OSError) as error:
            logger.warning("%s unreadable: %s", name, error)
            continue
        options = body.get("compilerOptions")
        paths = options.get("paths") if isinstance(options, dict) else None
        if not isinstance(paths, dict):
            continue
        for alias in paths:
            prefix = str(alias).rstrip("*")
            if prefix:
                prefixes.add(prefix)
    return frozenset(prefixes)


def build_repo_context(root: Path) -> RepoContext:
    python_deps, has_python = _python_dependencies(root)
    javascript_deps, has_javascript = _javascript_dependencies(root)
    return RepoContext(
        root=root,
        python_dependencies=python_deps,
        javascript_dependencies=javascript_deps,
        local_python_modules=_local_python_modules(root),
        has_python_manifest=has_python,
        has_javascript_manifest=has_javascript,
        javascript_alias_prefixes=_javascript_alias_prefixes(root),
    )


def build_inventory(workspace: Path) -> Inventory:
    analyzable: list[InventoryEntry] = []
    excluded: list[tuple[str, str]] = []
    candidates = sorted(
        entry for entry in workspace.rglob("*") if entry.is_file() and not entry.is_symlink()
    )
    for path in candidates:
        relative = str(path.relative_to(workspace))
        reason = exclusion_reason(path, workspace)
        if reason:
            excluded.append((relative, reason))
            continue
        language = language_for(path.name)
        if language not in CODE_LANGUAGES and language not in DOCUMENT_LANGUAGES:
            excluded.append((relative, f"unsupported language ({language})"))
            continue
        analyzable.append(InventoryEntry(path=path, relative=relative, language=language))
        if len(analyzable) >= ANALYSIS_MAX_FILES:
            logger.warning("File cap of %d reached, remaining files skipped", ANALYSIS_MAX_FILES)
            break
    repo = build_repo_context(workspace)
    logger.info("Inventory: %d analyzable, %d excluded", len(analyzable), len(excluded))
    return Inventory(analyzable=analyzable, excluded=excluded, repo=repo)


def load_context(entry: InventoryEntry, repo: RepoContext) -> FileContext | None:
    try:
        text = entry.path.read_text(encoding="utf-8", errors="replace")
    except OSError as error:
        logger.warning("Read failed for %s: %s", entry.relative, error)
        return None
    fingerprint_only = looks_minified(text)
    lines = text.splitlines()
    context = FileContext(
        path=entry.path,
        relative=entry.relative,
        language=entry.language,
        text=text,
        lines=lines,
        comments=extract_comments(entry.language, lines),
        repo=repo,
        fingerprint_only=fingerprint_only,
    )
    context.metrics = compute_metrics(entry.language, text)
    return context


def score_file(context: FileContext, baselines: RepoBaselines) -> FileScore:
    metrics = context.metrics
    if metrics is None:
        metrics = compute_metrics(context.language, context.text)
        context.metrics = metrics
    if context.fingerprint_only:
        signals = run_detectors(context, FINGERPRINT_DETECTORS)
        origin = fingerprint_origin_score(signals)
        quality = quality_deficit_score(signals, metrics)
        effective_sloc = max(metrics.sloc, min(metrics.total_lines, 20))
        return FileScore(
            relative=context.relative,
            language=context.language,
            sloc=effective_sloc,
            origin_score=origin,
            quality_deficit=quality,
            category=categorize(origin, quality),
            criticality=criticality_for(context.relative),
            signals=signals,
        )
    signals = run_detectors(context, DETECTORS)
    signals.extend(deviation_signals(context.relative, metrics, baselines))
    origin = origin_score(context.text, metrics, signals)
    quality = quality_deficit_score(signals, metrics)
    return FileScore(
        relative=context.relative,
        language=context.language,
        sloc=metrics.sloc,
        origin_score=origin,
        quality_deficit=quality,
        category=categorize(origin, quality),
        criticality=criticality_for(context.relative),
        signals=signals,
    )


def compute_repo_baselines(contexts: list[FileContext]) -> RepoBaselines:
    metrics_by_file: dict[str, FileMetrics] = {
        context.relative: context.metrics for context in contexts if context.metrics is not None
    }
    return compute_baselines(metrics_by_file)
