"""DB 분석 모드 runner.

룰 기반(정규식/AST)으로 Python 코드의 DB 관련 패턴을 스캔하여
SQL 인젝션 / ORM 성능 / 트랜잭션 / 마이그레이션 관련 finding 을 만든다.

인터페이스 모드의 UI 패턴을 따라가되, 인터페이스 모드 코드는 건드리지 않는다.
"""

from __future__ import annotations

from collections import defaultdict
import ast
import os
import re
from pathlib import Path
from typing import Dict, Iterable, List, Tuple

from common.context import ReviewFinding, ReviewReport, ReviewMode
from common.input_builder import build_input_context
from common.render import render_review_panel
from common.ui import console, finish_progress, start_progress, update_status
from rich.panel import Panel


DB_SECTION_ORDER: List[Tuple[str, str]] = [
    ("SQLi", "SQL 인젝션"),
    ("Performance", "ORM/쿼리 성능"),
    ("Transaction", "트랜잭션"),
    ("Migration", "마이그레이션"),
]


def _score_db_path(path: str) -> int:
    """DB 관련 파일을 앞쪽에 오도록 가중치 부여 (파일을 제외하지는 않음)."""
    lowered = path.lower()
    score = 0
    keywords = [
        "models.py",
        "model.py",
        "migrations",
        "migration",
        "repository",
        "repositories",
        "repo",
        "dao",
        "crud",
        "query",
        "queries",
        "db",
        "database",
        "schema",
    ]
    for kw in keywords:
        if kw in lowered:
            score += 5
    # Python 파일은 기본적으로 1점
    if lowered.endswith(".py"):
        score += 1
    return score


class _DBAnalyzer(ast.NodeVisitor):
    """단일 파일에서 DB 관련 패턴을 수집하는 AST 기반 분석기."""

    def __init__(self, file_path: str, source: str) -> None:
        self.file_path = file_path
        self.source = source
        self.findings: List[Tuple[str, str, int, str, str]] = []
        # 함수/메서드 단위로 쿼리 호출 수를 세기 위한 스택
        self._func_stack: List[Dict[str, int]] = []

    # 헬퍼
    def _add_finding(
        self,
        category: str,
        symbol: str,
        lineno: int,
        message: str,
        fix: str,
    ) -> None:
        self.findings.append((category, symbol, lineno, message, fix))

    def _current_func(self) -> Dict[str, int] | None:
        return self._func_stack[-1] if self._func_stack else None

    # 방문 로직
    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:  # type: ignore[override]
        self._func_stack.append({"writes": 0, "lineno": node.lineno, "name": node.name})
        self.generic_visit(node)
        func_info = self._func_stack.pop()

        # 트랜잭션 의심: 하나의 함수에서 write 쿼리가 2번 이상인데 transaction/atomic 등이 없음
        if func_info["writes"] >= 2:
            body_src = ast.get_source_segment(self.source, node) or ""
            if not re.search(r"(transaction|atomic|session\.begin|session\.commit|rollback)", body_src):
                self._add_finding(
                    "Transaction",
                    node.name,
                    node.lineno,
                    "여러 write 쿼리가 있지만 트랜잭션(atomic/transaction)이 사용되지 않아 데이터 정합성 문제가 발생할 수 있습니다.",
                    "여러 write 연산을 하나의 트랜잭션 블록으로 묶고, 실패 시 롤백되도록 처리하세요.",
                )

    def visit_Call(self, node: ast.Call) -> None:  # type: ignore[override]
        func = node.func
        func_name = ""
        if isinstance(func, ast.Attribute):
            func_name = func.attr
        elif isinstance(func, ast.Name):
            func_name = func.id

        # SQL 실행 패턴 (execute, raw 등)
        if func_name in {"execute", "executemany", "raw", "raw_query"}:
            # 첫 번째 인자에서 SQL 문자열 여부 확인
            sql_arg = node.args[0] if node.args else None
            is_dynamic = False
            sql_snippet = ""

            if isinstance(sql_arg, ast.BinOp) and isinstance(sql_arg.op, (ast.Add, ast.Mod)):
                is_dynamic = True
            elif isinstance(sql_arg, ast.JoinedStr):
                is_dynamic = True
            elif isinstance(sql_arg, ast.Constant) and isinstance(sql_arg.value, str):
                sql_snippet = sql_arg.value
                if "%" in sql_snippet or "{" in sql_snippet:
                    is_dynamic = True

            # SQL 키워드 포함 여부
            if isinstance(sql_arg, ast.Constant) and isinstance(sql_arg.value, str):
                sql_snippet = sql_arg.value

            if sql_snippet and not is_dynamic:
                lowered = sql_snippet.lower()
                if any(kw in lowered for kw in ("select ", "update ", "insert ", "delete ")):
                    # 정적 쿼리이지만 파라미터화 여부만 힌트로 남김
                    pass

            if is_dynamic:
                self._add_finding(
                    "SQLi",
                    func_name,
                    node.lineno,
                    "문자열 포매팅이나 f-string으로 동적 쿼리를 만들어 `execute`에 전달하고 있어 SQL 인젝션 취약점이 발생할 수 있습니다.",
                    "사용자 입력은 항상 파라미터 바인딩을 통해 전달하고, 문자열 결합으로 쿼리를 구성하지 마세요.",
                )

            # write 쿼리 카운트 (트랜잭션 분석용)
            sql_text = sql_snippet.lower() if sql_snippet else ""
            if any(kw in sql_text for kw in ("insert ", "update ", "delete ", "create ", "alter ", "drop ")):
                cur = self._current_func()
                if cur is not None:
                    cur["writes"] += 1

        # N+1 의심: 루프 안에서 ORM/쿼리 호출
        if func_name in {"filter", "get", "get_or_create", "first", "all", "objects"}:
            # 상위 노드가 For/AsyncFor 안에 있는지 간단히 확인 (line 기반 휴리스틱)
            # AST 상에서 정확한 부모를 추적하지는 않지만, 소스 레벨에서 \"for\" 가 앞에 있는지 대략 확인
            snippet = ast.get_source_segment(self.source, node) or ""
            prefix = self.source[: node.lineno - 1]
            if "for " in prefix:
                self._add_finding(
                    "Performance",
                    func_name,
                    node.lineno,
                    "루프 안에서 ORM 쿼리(`filter`/`get` 등)를 호출하고 있어 N+1 쿼리 문제가 발생할 수 있습니다.",
                    "루프 밖에서 미리 관련 데이터를 한 번에 조회하거나 `select_related` / `prefetch_related`와 같은 기능을 사용해 쿼리 수를 줄이세요.",
                )

        # 단순히 commit/rollback 호출 여부만 체크해서 트랜잭션 힌트에 활용
        if func_name in {"commit", "rollback"}:
            cur = self._current_func()
            if cur is not None:
                # commit/rollback이 있으면 writes 카운트는 트랜잭션 내라고 보고 별도 분석은 생략
                cur["writes"] = 0

        self.generic_visit(node)


def _analyze_file(path: str, content: str) -> List[Tuple[str, str, int, str, str]]:
    """단일 파일을 분석하여 finding 리스트를 반환.

    반환 형식: [(category, symbol, line, message, fix), ...]
    """
    findings: List[Tuple[str, str, int, str, str]] = []

    try:
        tree = ast.parse(content, filename=path)
    except SyntaxError:
        return findings

    analyzer = _DBAnalyzer(path, content)
    analyzer.visit(tree)
    findings.extend(analyzer.findings)

    # 마이그레이션 파일/내용에 대한 간단한 휴리스틱
    lowered = content.lower()
    if any(kw in path.lower() for kw in ("migrations", "migration")) or "op.alter_column" in content:
        # nullable 변경, 타입 변경 등 위험 단서 (단순 문자열 검색)
        for m in re.finditer(r"alter_column\([^)]*\)", content):
            lineno = content.count("\n", 0, m.start()) + 1
            findings.append(
                (
                    "Migration",
                    "alter_column",
                    lineno,
                    "마이그레이션에서 컬럼 속성을 변경하고 있어 서비스 중단/데이터 불일치 위험이 있을 수 있습니다.",
                    "대용량 테이블의 컬럼 변경은 배치/백필 단계로 나누고, 롤백 전략을 포함한 안전한 마이그레이션 전략을 설계하세요.",
                )
            )

    # SQL 텍스트 안에서 위험한 패턴 힌트 (간단 버전)
    for m in re.finditer(r"SELECT \\* FROM", content, flags=re.IGNORECASE):
        lineno = content.count("\n", 0, m.start()) + 1
        findings.append(
            (
                "Performance",
                "SELECT *",
                lineno,
                "`SELECT *`를 사용하면 불필요한 컬럼까지 모두 조회되어 성능에 영향을 줄 수 있습니다.",
                "실제로 필요한 컬럼만 명시적으로 선택하도록 쿼리를 수정하세요.",
            )
        )

    return findings


def _map_db_section(category: str) -> str | None:
    """카테고리를 DB 섹션 키로 매핑 (인터페이스 모드의 _map_interface_section 패턴)."""
    cat = (category or "").lower()
    if "sqli" in cat or "injection" in cat or "sql" in cat:
        return "SQLi"
    if "performance" in cat or "n+1" in cat or "query" in cat:
        return "Performance"
    if "transaction" in cat or "atomic" in cat:
        return "Transaction"
    if "migration" in cat or "migrate" in cat:
        return "Migration"
    return None


def _build_db_sections(findings: List[ReviewFinding]) -> Dict[str, List[Tuple[str, str]]]:
    """ReviewFinding 리스트를 sections 형식으로 변환 (인터페이스 모드의 _build_interface_sections 패턴)."""
    sections = {key: [] for key, _label in DB_SECTION_ORDER}
    for f in findings:
        if f.category == "system":
            continue
        section_key = _map_db_section(f.category)
        if not section_key:
            continue
        
        # 심볼 추출 (인터페이스 모드와 동일한 패턴)
        anchors: List[str] = []
        if isinstance(f.extra, dict):
            raw_anchors = f.extra.get("anchors")
            if isinstance(raw_anchors, list):
                anchors = [str(a).strip() for a in raw_anchors if str(a).strip()][:3]
        
        # 메시지에서 앵커 추출 시도
        if not anchors:
            anchor_match = re.search(r"\(anchor:\s*([^)]+)\)", f.message)
            if anchor_match:
                raw = anchor_match.group(1)
                anchors = [t.strip() for t in raw.split(",") if t.strip()][:3]
        
        # 메시지에서 토큰 추출 시도
        if not anchors:
            candidates = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", f.message)
            seen: set[str] = set()
            for token in candidates:
                if token in seen:
                    continue
                seen.add(token)
                anchors.append(token)
                if len(anchors) >= 3:
                    break
        
        if not anchors:
            continue
        
        if not f.suggestion:
            continue
        
        if f.file:
            try:
                filename = os.path.relpath(f.file, os.getcwd())
            except ValueError:
                filename = str(f.file)
        else:
            filename = "unknown"
        
        # 줄번호가 있으면 (line: 번호) 형식으로 추가
        if f.line:
            location = f"{filename}:{anchors[0]} (line: {f.line})"
        else:
            location = f"{filename}:{anchors[0]}"
        
        # 앵커 제거
        message = re.sub(r"\s*\(anchor:.*?\)\s*$", "", f.message).strip()
        anchor_text = ", ".join(anchors)
        risk_line = f"{location} — {message} (anchor: {anchor_text})"
        sections[section_key].append((risk_line, f.suggestion.strip()))
    
    return sections


def _format_db_panel(report: ReviewReport) -> Panel:
    """리뷰 리포트를 패널로 변환 (인터페이스 모드의 _format_interface_panel 패턴)."""
    files = [f.file.name for f in report.findings if f.file]
    summary = ", ".join(dict.fromkeys(files))
    if len(summary) > 80:
        summary = summary[:77] + "..."
    header = summary if summary else "no files"
    sections = _build_db_sections(report.findings)

    return render_review_panel(
        mode="db",
        file_summary=header,
        section_order=DB_SECTION_ORDER,
        sections=sections,
        title="DB Review",
    )


def build_db_panel(report: ReviewReport) -> Panel:
    """리뷰 리포트를 패널로 반환 (인터페이스 모드의 build_interface_panel 패턴)."""
    return _format_db_panel(report)


def run(input_context: dict | None = None) -> int:
    """DB 모드 진입점 (인터페이스 모드의 run 패턴을 따름).

    - staged / all 스코프 모두 지원 (공통 build_input_context 사용)
    - Python 파일(.py)만 분석
    - DB 관련 파일을 우선적으로 분석하지만, 다른 파일도 모두 포함
    """
    try:
        # 파일 목록 결정 (input_context에서 files 가져오기)
        if not input_context:
            handle = start_progress("DB 분석 모드", total_files=0)
            update_status(handle, "결과 파싱/렌더링 중")
            finish_progress(handle)
            panel = render_review_panel(
                mode="db",
                file_summary="no files",
                section_order=DB_SECTION_ORDER,
                sections={key: [] for key, _ in DB_SECTION_ORDER},
                title="DB Review",
                notice="리뷰 대상 파일이 없습니다.",
            )
            console.print(panel)
            return 1

        # input_context에서 파일 목록 가져오기
        files = input_context.get("files", [])
        if not files:
            handle = input_context.get("_ui_handle") or start_progress("DB 분석 모드", total_files=0)
            update_status(handle, "결과 파싱/렌더링 중")
            finish_progress(handle)
            panel = render_review_panel(
                mode="db",
                file_summary="no files",
                section_order=DB_SECTION_ORDER,
                sections={key: [] for key, _ in DB_SECTION_ORDER},
                title="DB Review",
                notice="리뷰 대상 파일이 없습니다.",
            )
            console.print(panel)
            return 1

        # Python 파일만 필터링
        python_files = [f for f in files if str(f.get("path", "")).endswith(".py")]

        # DB 관련 파일을 먼저 분석하되, 나머지도 순서대로 포함
        python_files_sorted = sorted(
            python_files, key=lambda f: -_score_db_path(str(f.get("path", "")))
        )

        # progress handle 초기화 (파일 목록 결정 후)
        handle = input_context.get("_ui_handle") or start_progress("DB 분석 모드", total_files=len(python_files_sorted))
        if input_context:
            input_context["_ui_handle"] = handle

        # ReviewFinding 리스트로 변환
        review_findings: List[ReviewFinding] = []

        total = len(python_files_sorted)
        for idx, file_entry in enumerate(python_files_sorted, 1):
            path = str(file_entry.get("path", "unknown"))
            content = str(file_entry.get("content") or "")

            # 파일 분석 진행률 업데이트 (프로그레스 바로 대체)
            from common.ui import update_progress
            update_progress(handle, completed=idx)

            # AST 분석으로 finding 추출
            raw_findings = _analyze_file(path, content)
            for category, symbol, line, message, fix in raw_findings:
                review_findings.append(
                    ReviewFinding(
                        file=Path(path) if path != "unknown" else None,
                        line=line,
                        category=category,
                        severity="high" if category == "SQLi" else "medium" if category == "Transaction" else "low",
                        message=message,
                        suggestion=fix,
                        extra={"anchors": [symbol]},
                    )
                )

        # ReviewReport 생성
        report = ReviewReport(
            mode=ReviewMode.INTERFACE,  # ReviewMode에 DB가 없으므로 임시로 INTERFACE 사용
            findings=review_findings,
        )

        # 결과 출력 (인터페이스 모드와 동일한 패턴)
        update_status(handle, "결과 파싱/렌더링 중")
        panel = build_db_panel(report)
        finish_progress(handle)
        console.print(panel)

        # DB 모드에서는 정상 완료 시 항상 exit code 0 반환
        # (문제 발견은 정상적인 리뷰 결과이므로 오류로 처리하지 않음)
        return 0

    except KeyboardInterrupt:
        finish_progress(handle)
        console.print("[yellow]DB 분석 모드 중단.[/yellow]")
        return 1
    except Exception as e:
        finish_progress(handle)
        console.print(
            f"\n[bold red]DB 분석 모드 실행 중 예상치 못한 오류 발생:[/bold red] [red]{e}[/red]",
            stderr=True,
        )
        import traceback
        traceback.print_exc()
        return 1

