"""Security mode runner."""

from __future__ import annotations

import os
import re
from typing import List, Optional, Tuple
from common.ui import console, finish_progress, start_progress, update_status, update_progress
from .git_utils import get_staged_blob, get_staged_paths, is_binary_blob, is_binary_path, is_git_repo
from .llm import chat_completions
from .prompts import SYSTEM_PROMPT, USER_PROMPT_TEMPLATE
from .scoring import sort_by_score
from common.render import render_review_panel
from common.input_builder import build_input_context
from security.context_engine import run_context_review_from_entries
from security.prompts import SYSTEM_PROMPT_JSON, SYSTEM_PROMPT_JSON_MULTI, build_security_user_prompt

TOP_N = int(os.getenv("GAR_SECURITY_TOP_N", "20"))
MAX_FILE_CHARS = int(os.getenv("GAR_SECURITY_MAX_CHARS", "80000"))

EXCLUDED_DIRS = {".git", "node_modules", "dist", "build", ".venv", "__pycache__", "docs", "gar.egg-info"}
EXCLUDED_SUFFIXES = (".lock", ".min.js", ".md")

TAG_ORDER = [
    ("Access", "인증·인가"),
    ("Injection", "입력값 검증"),
    ("Sensitive", "민감정보 노출"),
    ("Crypto", "암호화·시크릿"),
    ("External", "외부 통신"),
]
TAG_KR_MAP = {kr: key for key, kr in TAG_ORDER}

def _extract_line_number_any(text: str) -> int | None:
    """LLM 출력에서 line 번호를 최대한 관대하게 추출한다.

    예: "(line: 12)", "(anchor: ..., line: 78)", "line: 51" 등.
    """
    if not text:
        return None
    m = re.search(r"\bline:\s*(\d+)\b", text)
    if not m:
        return None
    try:
        return int(m.group(1))
    except Exception:
        return None


def _is_excluded(path: str) -> bool:
    parts = path.split("/")
    if any(part in EXCLUDED_DIRS for part in parts):
        return True
    return path.endswith(EXCLUDED_SUFFIXES)


def _read_staged_text(path: str) -> Tuple[Optional[str], bool, Optional[str]]:
    if is_binary_path(path):
        return None, False, "binary file"
    blob = get_staged_blob(path)
    if blob is None:
        return None, False, "failed to read staged content"
    if is_binary_blob(blob):
        return None, False, "binary content"
    text = blob.decode("utf-8", errors="replace")
    if len(text) > MAX_FILE_CHARS:
        return text[:MAX_FILE_CHARS], True, None
    return text, False, None


def _select_targets(paths: List[str]) -> Tuple[List[str], bool]:
    if len(paths) <= 5:
        return paths, False
    scored = sort_by_score(paths)
    selected = [path for path, _ in scored[:TOP_N]]
    return selected, True


def _build_files_blob(paths: List[str]) -> Tuple[str, bool, List[str]]:
    truncated_any = False
    skipped: List[str] = []
    chunks: List[str] = []
    for path in paths:
        if _is_excluded(path):
            skipped.append(f"{path} (excluded)")
            continue
        content, truncated, reason = _read_staged_text(path)
        if content is None:
            skipped.append(f"{path} ({reason})")
            continue
        truncated_any = truncated_any or truncated
        chunks.append(f"### {path}\n{content}\n")
    return "\n".join(chunks), truncated_any, skipped


def _build_files_blob_from_entries(files: List[dict]) -> Tuple[str, bool, List[str]]:
    truncated_any = False
    skipped: List[str] = []
    chunks: List[str] = []
    for entry in files:
        path = entry.get("path")
        content = entry.get("content", "")
        if not path or not isinstance(content, str):
            skipped.append("invalid entry")
            continue
        truncated_any = truncated_any or bool(entry.get("truncated"))
        chunks.append(f"### {path}\n{content}\n")
    return "\n".join(chunks), truncated_any, skipped


def _strip_code_fences(text: str) -> str:
    if not text:
        return ""
    # 코드 블록 전체를 제거하지 말고, 펜스만 걷어내며 본문은 유지한다.
    # (LLM이 ```로 감싸면 기존 구현은 전부 삭제되어 No Findings로 떨어짐)
    return re.sub(r"```(?:\w+)?\n(.*?)```", r"\1", text, flags=re.DOTALL).strip()


def _extract_sections(text: str) -> dict[str, list[str]]:
    """섹션 기반 파싱 - 매우 관대하게 모든 내용 추출"""
    lines = [line.strip() for line in text.splitlines() if line.strip()]
    sections = {key: [] for key, _ in TAG_ORDER}
    current = None
    
    for line in lines:
        # 섹션 헤더 찾기
        if line.startswith("[") and line.endswith("]"):
            tag = line.strip("[]")
            if tag in TAG_KR_MAP:
                current = TAG_KR_MAP[tag]
            continue
        
        # 현재 섹션에 내용 추가
        if current:
            content = None
            if line.startswith("- "):
                content = line[2:].strip()
            elif line.startswith("• "):
                content = line[2:].strip()
            elif line.startswith("* "):
                content = line[2:].strip()
            elif line.startswith("-"):
                content = line[1:].strip()
            else:
                content = line.strip()
            
            # "없음"만 있는 경우만 제외 (부분 포함은 허용)
            if content and content.lower() not in ("없음", "no findings"):
                sections[current].append(content)
        else:
            # 섹션이 없어도 내용이 있으면 기본 섹션에 추가
            content = line.strip()
            if content and content.lower() not in ("없음", "[no findings]", "no findings"):
                # 첫 번째 섹션에 추가
                first_tag = list(TAG_ORDER)[0][0] if TAG_ORDER else "Access"
                sections[first_tag].append(content)
    
    return sections


def _pair_item_lines(lines: List[str]) -> List[Tuple[str, str]]:
    pairs: List[Tuple[str, str]] = []
    i = 0
    while i < len(lines):
        risk = lines[i]
        fix = lines[i + 1] if i + 1 < len(lines) else ""
        if fix:
            pairs.append((risk, fix))
        i += 2
    return pairs


def _format_fix_line(line: str) -> str:
    return re.sub(r"^[^:\s]+:[^—-]+\s*[—-]\s*", "", line).strip()


def _is_valid_fix_line(line: str) -> bool:
    if "(anchor:" in line or "—" in line:
        return False
    action_verbs = [
        "변경", "추가", "제거", "차단", "검증", "제한", "마스킹",
        "파라미터화", "화이트리스트", "이스케이프", "바인딩",
    ]
    if not any(verb in line for verb in action_verbs):
        return False
    phenomenon_terms = ["노출", "가능", "존재", "발생", "위험"]
    if any(term in line for term in phenomenon_terms):
        return False
    return True


def _format_header(paths: List[str]) -> str:
    if not paths:
        return "security: no staged files"
    if len(paths) == 1:
        return f"security: {paths[0]}"
    summary = ", ".join(paths[:3])
    if len(paths) > 3:
        summary += f" ... (+{len(paths) - 3})"
    return f"security: {summary}"


def _strip_bullet_prefix(text: str) -> str:
    stripped = text.strip()
    for prefix in ("- ", "• ", "* "):
        if stripped.startswith(prefix):
            return stripped[len(prefix):].strip()
    return stripped


def _parse_file_blocks(text: str, available_paths: List[str] | None = None) -> List[Tuple[str, str]]:
    """매우 관대한 파서: LLM이 출력하는 모든 형식을 최대한 파싱 - 필터링 최소화"""
    pairs: List[Tuple[str, str]] = []
    current_path = None
    pending_risk = None
    
    # 필터링 거의 제거: 빈 텍스트만 체크
    if not text or not text.strip():
        return []
    
    # [No Findings]만 있는 경우 명시적으로 필터링
    cleaned_text = text.strip()
    if cleaned_text.lower() in ("[no findings]", "no findings", "없음", "- 없음"):
        return []
    
    # available_paths에서 기본 파일 경로 선택
    default_path = None
    if available_paths:
        for avail_path in available_paths:
            if avail_path.endswith((".py", ".pyi", ".js", ".ts")):
                default_path = avail_path
                break
    
    for raw in text.splitlines():
        line = raw.strip()
        if not line:
            continue
        
        # 헤더 패턴: [파일경로] (총 N개) 또는 (총 N개)
        header_match = re.match(r"^\[(?P<path>[^]]+)\]\s*\(총\s*\d+개\)", line)
        if header_match:
            current_path = header_match.group("path").strip()
            pending_risk = None
            continue
        
        # 헤더 패턴: (총 N개)만 있는 경우
        header_only_match = re.match(r"^\(총\s*\d+개\)", line)
        if header_only_match:
            if not current_path and default_path:
                current_path = default_path
            pending_risk = None
            continue
        
        # [No Findings] 헤더도 처리 (하지만 내용은 파싱)
        if line.strip().lower() in ("[no findings]", "[no findings]", "no findings", "없음", "- 없음"):
            # 헤더로 인식하고 건너뛰기 (내용이 없으면 빈 리스트 반환)
            if not current_path and default_path:
                current_path = default_path
            pending_risk = None
            continue
        
        # Fix: 줄 처리
        fix_match = re.match(r"^Fix:\s*(?P<fix>.+)\s*$", line, re.IGNORECASE)
        if fix_match:
            if pending_risk:
                pairs.append((pending_risk, fix_match.group("fix").strip()))
                pending_risk = None
            continue
        
        # - 로 시작하는 모든 줄을 항목으로 처리 (매우 관대)
        if line.startswith("-"):
            # 기존 pending_risk가 있으면 저장 (Fix 없이)
            if pending_risk:
                pairs.append((pending_risk, ""))
            
            # - 제거
            content = line[1:].strip()
            
            # 파일 경로 추출 시도 (path: 형식)
            path = None
            sym = None
            risk = None
            anchor = None
            line_number = None
            
            # 패턴 1: path:symbol — risk (anchor: ...)
            match1 = re.match(r"^(?P<path>[^:\s]+):(?P<sym>[^—-]+)\s*[—-]\s*(?P<risk>.+?)(?:\s*\(anchor:\s*(?P<anchor>.+)\))?\s*$", content)
            if match1:
                path = match1.group("path").strip()
                sym = match1.group("sym").strip()
                risk = match1.group("risk").strip()
                anchor = match1.group("anchor")
            else:
                # 패턴 2: [symbol] — risk (anchor: ...)
                match2 = re.match(r"^\[(?P<sym>[^\]]+)\]\s*[—-]\s*(?P<risk>.+?)(?:\s*\(anchor:\s*(?P<anchor>.+)\))?\s*$", content)
                if match2:
                    sym = match2.group("sym").strip()
                    risk = match2.group("risk").strip()
                    anchor = match2.group("anchor")
                else:
                    # 패턴 3: symbol — risk (anchor: ...) 또는 — risk (anchor: ...)
                    # — 또는 - 구분자 찾기
                    if " — " in content or " - " in content:
                        sep = " — " if " — " in content else " - "
                        parts = content.split(sep, 1)
                        if len(parts) == 2:
                            sym_part = parts[0].strip()
                            risk_part = parts[1].strip()
                            
                            # <function ...> 형식 처리
                            if sym_part.startswith("<") and ">" in sym_part:
                                match = re.search(r"<function\s+(\w+)>", sym_part)
                                if match:
                                    sym = match.group(1)
                                else:
                                    sym = sym_part.strip("<>").replace("function ", "").strip()
                            else:
                                # sym_part가 코드 스니펫인지 확인 (특수문자 포함, 너무 긴 경우)
                                is_code_snippet = bool(re.search(r'[{}()\[\]":,;]', sym_part)) or len(sym_part) > 30
                                
                                if is_code_snippet:
                                    # 코드 스니펫에서 심볼 추출
                                    # 변수 할당 패턴 (예: "API_KEY = ...")
                                    var_assign_match = re.search(r'\b([A-Z_][A-Z0-9_]*)\s*=', sym_part)
                                    if var_assign_match:
                                        sym = var_assign_match.group(1)
                                    else:
                                        # 함수 호출 패턴 (예: "cursor.execute(...)")
                                        func_call_match = re.search(r'\b([A-Za-z_][A-Za-z0-9_]*)\s*\(', sym_part)
                                        if func_call_match:
                                            sym = func_call_match.group(1)
                                        else:
                                            # 코드 스니펫에서 첫 번째 유효한 식별자 추출
                                            identifiers = re.findall(r'\b([A-Za-z_][A-Za-z0-9_]{2,})\b', sym_part)
                                            for candidate in identifiers:
                                                if candidate not in ('def', 'class', 'if', 'for', 'while', 'return', 'import', 'from', 'as', 'in', 'is', 'and', 'or', 'not', 'True', 'False', 'None', 'f', 'UPDATE', 'SELECT', 'WHERE', 'SET'):
                                                    sym = candidate
                                                    break
                                    
                                    # 코드 스니펫에서 라인 번호 추출 시도 (LLM이 제공한 정보만 사용)
                                    # risk_part나 anchor에서 라인 번호 찾기 (LLM이 제공한 경우만)
                                    # (line: 23) 또는 (line 23) 형식 모두 처리
                                    ln = _extract_line_number_any(risk_part)
                                    if ln is not None:
                                        line_number = str(ln)
                                    elif anchor:
                                        ln2 = _extract_line_number_any(anchor)
                                        if ln2 is not None:
                                            line_number = str(ln2)
                                else:
                                    sym = sym_part if sym_part else None
                            
                            # anchor 추출
                            anchor_match = re.search(r"\(anchor:\s*(.+?)\)", risk_part)
                            if anchor_match:
                                anchor = anchor_match.group(1).strip()
                                risk = risk_part[:anchor_match.start()].strip()
                            else:
                                risk = risk_part
                            
                            # sym_part가 비어있을 때도 line_number 추출 시도
                            if not sym_part or sym_part.strip() == "":
                                # risk_part나 anchor에서 라인 번호 찾기
                                ln = _extract_line_number_any(risk_part)
                                if ln is not None:
                                    line_number = str(ln)
                                elif anchor:
                                    ln2 = _extract_line_number_any(anchor)
                                    if ln2 is not None:
                                        line_number = str(ln2)
                        else:
                            # 구분자가 없으면 전체를 risk로
                            risk = content
                    else:
                        # 구분자가 없으면 전체를 risk로
                        risk = content
            
            # path가 파일 경로가 아니면 current_path 또는 default_path 사용
            if path and not path.endswith((".py", ".pyi", ".js", ".ts")):
                path = None
            
            if not path:
                path = current_path or default_path or "unknown"
            
            if not sym:
                # anchor에서 심볼 추출 시도
                if anchor:
                    # anchor에서 함수명/변수명 추출
                    anchor_parts = [a.strip() for a in anchor.split(",")]
                    for part in anchor_parts:
                        # def function_name( 패턴 찾기
                        def_match = re.search(r'\bdef\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(', part)
                        if def_match:
                            sym = def_match.group(1)
                            break
                        # class ClassName( 패턴 찾기
                        class_match = re.search(r'\bclass\s+([A-Za-z_][A-Za-z0-9_]*)\s*[\(:]', part)
                        if class_match:
                            sym = class_match.group(1)
                            break
                        # 함수 호출 패턴 (예: cursor.execute, subprocess.check_output)
                        func_call_match = re.search(r'\b([A-Za-z_][A-Za-z0-9_]*)\s*\(', part)
                        if func_call_match:
                            candidate = func_call_match.group(1)
                            # Python 키워드 및 일반적인 단어 제외
                            if candidate not in ('if', 'for', 'while', 'return', 'print', 'len', 'str', 'int', 'list', 'dict', 'open', 'with'):
                                sym = candidate
                                break
                        # 변수명 패턴 (대문자로 시작하는 변수명)
                        var_match = re.search(r'\b([A-Z_][A-Z0-9_]*)\s*=', part)
                        if var_match:
                            sym = var_match.group(1)
                            break
                        # 일반 변수명/함수명
                        word_match = re.search(r'\b([A-Za-z_][A-Za-z0-9_]{2,})\b', part)
                        if word_match:
                            candidate = word_match.group(1)
                            # Python 키워드 제외
                            if candidate not in ('def', 'class', 'if', 'for', 'while', 'return', 'import', 'from', 'as', 'in', 'is', 'and', 'or', 'not', 'True', 'False', 'None', 'cursor', 'subprocess', 'requests', 'Response'):
                                sym = candidate
                                break
                    # 여전히 없으면 첫 번째 anchor 사용
                    if not sym and anchor_parts:
                        # 첫 번째 anchor에서 함수 호출이나 변수명 추출 시도
                        first_part = anchor_parts[0].strip()
                        # 함수 호출 패턴 (예: cursor.execute)
                        func_call = re.search(r'\.([A-Za-z_][A-Za-z0-9_]*)\s*\(', first_part)
                        if func_call:
                            sym = func_call.group(1)
                        else:
                            sym = first_part
                else:
                    # risk나 content에서 심볼 추출 시도
                    search_text = risk if risk else content
                    if search_text:
                        # def function_name( 패턴 찾기
                        def_match = re.search(r'\bdef\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(', search_text)
                        if def_match:
                            sym = def_match.group(1)
                        else:
                            # 변수명 패턴 (대문자로 시작하는 변수명)
                            var_match = re.search(r'\b([A-Z_][A-Z0-9_]*)\s*=', search_text)
                            if var_match:
                                sym = var_match.group(1)
                            else:
                                # 일반 변수명/함수명
                                word_match = re.search(r'\b([A-Za-z_][A-Za-z0-9_]{2,})\b', search_text)
                                if word_match:
                                    candidate = word_match.group(1)
                                    if candidate not in ('def', 'class', 'if', 'for', 'while', 'return', 'import', 'from', 'as', 'in', 'is', 'and', 'or', 'not', 'True', 'False', 'None', '취약점', '인젝션', '노출'):
                                        sym = candidate
                
                # 여전히 없으면 "항목"
                if not sym:
                    sym = "항목"
            
            # <function ...> 형식에서 실제 함수명 추출 (아직 안 된 경우)
            if sym and sym.startswith("<") and ">" in sym:
                match = re.search(r"<function\s+(\w+)>", sym)
                if match:
                    sym = match.group(1)
                else:
                    sym = sym.strip("<>").replace("function ", "").strip()
            
            # sym이 여전히 없거나 빈 문자열이면 "항목"으로 확실히 설정
            if not sym or not sym.strip():
                sym = "항목"
            
            # risk가 없으면 content 전체를 risk로 사용
            if not risk:
                risk = content  # 전체 내용을 risk로 사용
            
            # risk에서 line 정보 추출 시도 (예: "API_KEY = ... (line 22)" 또는 "API_KEY = ... (line: 22)")
            # line_number는 이미 위에서 초기화되었으므로 여기서는 추출만 시도
            if not line_number:
                ln = _extract_line_number_any(risk)
                if ln is not None:
                    line_number = str(ln)
                    # risk에서 line 정보(있으면) 제거 (나중에 symbol에 추가)
                    risk = re.sub(r"\s*\bline:\s*\d+\b\s*", " ", risk).strip()
            
            # risk에서 코드 스니펫 추출 시도 (예: "API_KEY = ...")
            if not sym or sym == "항목":
                # risk가 코드 스니펫인지 확인 (특수문자 포함, 너무 긴 경우)
                is_code_snippet = bool(re.search(r'[{}()\[\]":,;]', risk)) or len(risk) > 50
                
                if is_code_snippet:
                    # 코드 스니펫에서 변수명/함수명 추출
                    # 변수 할당 패턴 (예: "API_KEY = ...")
                    var_assign_match = re.search(r'\b([A-Z_][A-Z0-9_]*)\s*=', risk)
                    if var_assign_match:
                        sym = var_assign_match.group(1)
                    else:
                        # 함수 호출 패턴 (예: "cursor.execute(...)")
                        func_call_match = re.search(r'\b([A-Za-z_][A-Za-z0-9_]*)\s*\(', risk)
                        if func_call_match:
                            sym = func_call_match.group(1)
                        else:
                            # 코드 스니펫에서 첫 번째 유효한 식별자 추출
                            # 문자열 리터럴 제외
                            identifiers = re.findall(r'\b([A-Za-z_][A-Za-z0-9_]{2,})\b', risk)
                            for candidate in identifiers:
                                if candidate not in ('def', 'class', 'if', 'for', 'while', 'return', 'import', 'from', 'as', 'in', 'is', 'and', 'or', 'not', 'True', 'False', 'None', '취약점', '인젝션', '노출', '하드코딩된', '사용되었습니다', '존재합니다', 'username', 'password', 'jsonify', 'request'):
                                    sym = candidate
                                    break
                    
                    # 파일 검색은 제거 - 렌더링 단계에서 _find_function_line_number로 처리
                    # LLM이 명시적으로 제공한 라인 번호만 사용
                else:
                    # 일반 텍스트에서 취약점 유형에 따라 관련 키워드 찾기
                    # SQL 인젝션 관련 키워드
                    if 'SQL' in risk or 'sql' in risk.lower() or '인젝션' in risk:
                        sql_keywords = ['execute', 'query', 'cursor', 'fetchone', 'fetchall', 'fetchmany']
                        for keyword in sql_keywords:
                            if keyword in risk.lower():
                                sym = keyword
                                break
                    # 명령 주입 관련 키워드
                    elif '명령' in risk or 'command' in risk.lower() or '주입' in risk:
                        cmd_keywords = ['check_output', 'Popen', 'system', 'call', 'run', 'exec']
                        for keyword in cmd_keywords:
                            if keyword in risk.lower():
                                sym = keyword
                                break
                    # 경로 탐색 관련 키워드
                    elif '경로' in risk or 'path' in risk.lower() or '파일' in risk:
                        path_keywords = ['open', 'read', 'write', 'file', 'path']
                        for keyword in path_keywords:
                            if keyword in risk.lower():
                                sym = keyword
                                break
                    # XSS 관련 키워드
                    elif 'XSS' in risk or 'xss' in risk.lower():
                        xss_keywords = ['render', 'html', 'Response', 'escape']
                        for keyword in xss_keywords:
                            if keyword in risk.lower():
                                sym = keyword
                                break
                    # SSRF 관련 키워드
                    elif 'SSRF' in risk or 'ssrf' in risk.lower():
                        ssrf_keywords = ['requests', 'get', 'post', 'urlopen', 'fetch']
                        for keyword in ssrf_keywords:
                            if keyword in risk.lower():
                                sym = keyword
                                break
                    
                    # 위에서 찾지 못했으면 일반 식별자 추출
                    if not sym or sym == "항목":
                        identifier_match = re.search(r'\b([A-Za-z_][A-Za-z0-9_]{2,})\b', risk)
                        if identifier_match:
                            candidate = identifier_match.group(1)
                            if candidate not in ('def', 'class', 'if', 'for', 'while', 'return', 'import', 'from', 'as', 'in', 'is', 'and', 'or', 'not', 'True', 'False', 'None', '취약점', '인젝션', '노출', '하드코딩된', '사용되었습니다', '존재합니다'):
                                sym = candidate
            
            # line_number가 있으면 실제 파일에서 함수명 찾기 시도 (sym이 없거나 "항목"일 때)
            if line_number and path and path != "unknown":
                # sym이 없거나 "항목"이면 실제 파일에서 찾기
                if not sym or sym == "항목" or sym.strip() == "":
                    # 파일 경로 해석
                    resolved_path = None
                    if os.path.isfile(path):
                        resolved_path = path
                    elif not os.path.isabs(path):
                        rel_path = os.path.join(os.getcwd(), path)
                        if os.path.isfile(rel_path):
                            resolved_path = rel_path
                    
                    if resolved_path and resolved_path.endswith((".py", ".pyi")):
                        try:
                            with open(resolved_path, "r", encoding="utf-8", errors="replace") as f:
                                lines = f.readlines()
                            target_line = int(line_number)
                            if target_line <= len(lines):
                                # 해당 라인 주변에서 함수 정의 찾기 (위로 최대 50줄까지)
                                for i in range(max(0, target_line - 50), target_line):
                                    line = lines[i].strip()
                                    # 함수 정의 패턴 찾기
                                    func_match = re.search(r'^\s*def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(', line)
                                    if func_match:
                                        sym = func_match.group(1)
                                        break
                                # 함수를 찾지 못했으면 해당 라인에서 변수/함수 호출 찾기
                                if not sym or sym == "항목":
                                    target_line_content = lines[target_line - 1] if target_line > 0 else ""
                                    # 변수 할당 패턴 (예: API_KEY = ...)
                                    var_match = re.search(r'\b([A-Z_][A-Z0-9_]*)\s*=', target_line_content)
                                    if var_match:
                                        sym = var_match.group(1)
                                    else:
                                        # 함수 호출 패턴 (예: cursor.execute(...))
                                        func_call_match = re.search(r'\b([A-Za-z_][A-Za-z0-9_]*)\s*\(', target_line_content)
                                        if func_call_match:
                                            candidate = func_call_match.group(1)
                                            # Python 키워드 제외
                                            if candidate not in ('if', 'for', 'while', 'return', 'print', 'len', 'str', 'int', 'list', 'dict'):
                                                sym = candidate
                        except Exception:
                            pass
            
            # line_number가 있으면 symbol에 추가
            if line_number and sym:
                sym = f"{sym} (line: {line_number})"
            elif line_number:
                sym = f"항목 (line: {line_number})"
            
            # 필터링 완전 제거: 모든 항목 추가
            # pending_risk 생성
            if anchor:
                pending_risk = f"{path}:{sym} — {risk} (anchor: {anchor})"
            else:
                pending_risk = f"{path}:{sym} — {risk}"
            continue
    
    # 마지막 pending_risk 처리
    if pending_risk:
        pairs.append((pending_risk, ""))
    
    return pairs


def run(input_context: dict | None = None) -> int:
    """Execute security mode analysis."""
    # input_context가 없으면 생성
    if input_context is None:
        # 먼저 파일 개수만 계산
        temp_context = build_input_context(
            mode="security",
            scope="staged",
            status_hook=lambda text: None,  # 임시로 아무것도 하지 않음
        )
        file_count = len(temp_context.get("files", []))
        # progress handle 생성
        handle = start_progress("보안 분석 모드", total_files=file_count)
        # 실제 input_context 생성 (status_hook 포함)
        input_context = build_input_context(
            mode="security",
            scope="staged",
            status_hook=lambda text: update_status(handle, text),
        )
        input_context["_ui_handle"] = handle
    else:
        # input_context가 이미 있으면 파일 개수 계산
        file_count = len(input_context.get("files", []))
        handle = input_context.get("_ui_handle") if input_context.get("_ui_handle") else start_progress("보안 분석 모드", total_files=file_count)
        if not input_context.get("_ui_handle"):
            input_context["_ui_handle"] = handle
    
    if not is_git_repo():
        finish_progress(handle)
        console.print("security: git repository가 아닙니다.")
        return 1

    scope = input_context.get("scope", "staged")
    
    # 이제 staged / all 모두 동일한 보안 파이프라인(일반 모드 방식)을 사용한다.
    # - scope="staged": build_input_context 가 staged 기준으로 files 를 구성
    # - scope="all"   : build_input_context 가 전체 코드 기준으로 files 를 구성
    # 두 경우 모두 run_context_review_from_entries + _convert_findings_to_security_sections 를 사용.
    return _run_security_with_normal_pipeline(input_context)


def _run_security_staged_mode(input_context: dict) -> int:
    """기존 staged 모드 로직 (변경 없음)."""
    handle = input_context.get("_ui_handle")
    
    staged_paths = get_staged_paths()
    if not staged_paths:
        finish_progress(handle)
        console.print("security: staged 변경 파일이 없습니다.")
        return 1

    # 파일 개수 계산
    file_count = len(staged_paths)

    # 파일 수집 단계
    update_status(handle, "파일 수집 중")
    update_progress(handle, completed=0)
    
    # 기존 로직: staged_paths에서 직접 파일 읽기
    targets = staged_paths
    files_blob, truncated_any, skipped = _build_files_blob(staged_paths)

    if not files_blob.strip():
        finish_progress(handle)
        console.print("security: 분석할 텍스트 파일이 없습니다.")
        if skipped:
            console.print("(참고) 일부 파일은 제외되었거나 읽기에 실패했습니다.", style="dim")
        return 1

    # 파일 수집 완료
    update_progress(handle, completed=file_count // 3)  # 약 33% 완료

    user_prompt = USER_PROMPT_TEMPLATE.format(files_blob=files_blob)
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": user_prompt},
    ]

    try:
        update_status(handle, "LLM 호출 중")
        update_progress(handle, completed=file_count * 2 // 3)  # 약 66% 완료
        response = chat_completions(messages)
    except Exception as exc:  # noqa: BLE001 - user-friendly message
        finish_progress(handle)
        console.print(f"security: LLM 호출 실패 - {exc}")
        return

    try:
        update_status(handle, "결과 파싱/렌더링 중")
        update_progress(handle, completed=file_count)  # 100% 완료
        cleaned = _strip_code_fences(response)
        if os.getenv("GAR_DEBUG_LLM") == "1":
            console.print("[dim]LLM output (cleaned):[/dim]")
            console.print(cleaned)
            console.print(f"[dim]Length: {len(cleaned)} chars[/dim]")
        
        # 일반/인터페이스 모드처럼 직접 파싱 (모든 항목 허용)
        file_block_pairs = _parse_file_blocks(cleaned, available_paths=targets)
        
        # 디버그: 파싱 결과 확인
        if os.getenv("GAR_DEBUG_LLM") == "1":
            console.print(f"[dim]Parsed file_block_pairs: {len(file_block_pairs)} items[/dim]")
            if file_block_pairs:
                console.print(f"[dim]First pair: {file_block_pairs[0][:100]}...[/dim]")
        
        # sections 구성 (일반/인터페이스 모드 방식)
        sections: dict[str, list[tuple[str, str]]] = {key: [] for key, _label in TAG_ORDER}
        # __file__ 키도 초기화 (필요할 수 있음)
        sections["__file__"] = []
        
        # file_block_pairs가 있으면 모든 항목을 sections에 추가 (필터링 없음)
        if file_block_pairs:
            # file_block_pairs의 각 항목을 후처리하여 실제 파일에서 함수명 추출 (all 모드와 동일한 로직)
            enhanced_pairs = []
            for risk_line, fix in file_block_pairs:
                # risk_line 파싱: "path:symbol (line: N) — risk" 또는 "path:symbol — risk (line: N)"
                # 라인 번호 추출
                line_number = _extract_line_number_any(risk_line)
                if line_number is not None:
                    # path 추출
                    path_match = re.match(r'^([^:]+):', risk_line)
                    if path_match:
                        file_path = path_match.group(1)
                        # 실제 파일에서 함수명 추출 (all 모드와 동일한 로직 사용)
                        # risk_line에서 message 추출 시도
                        message_match = re.search(r'—\s*(.+?)(?:\s*\(anchor:|$)', risk_line)
                        message = message_match.group(1).strip() if message_match else ""
                        symbol, _ = _extract_symbol_from_file(file_path, (line_number, line_number), message)
                        if symbol and symbol != "항목":
                            # risk_line에서 기존 symbol을 새로운 symbol로 교체
                            # "path:old_symbol (line: N) — risk" -> "path:new_symbol (line: N) — risk"
                            risk_line = re.sub(
                                r'^([^:]+):[^—(]+(\(line:\s*\d+\))\s*—',
                                rf'\1:{symbol} \2 —',
                                risk_line
                            )
                            # "path:old_symbol — risk" -> "path:new_symbol (line: N) — risk" (라인 번호가 뒤에 있는 경우)
                            if f"(line: {line_number})" not in risk_line:
                                risk_line = re.sub(
                                    r'^([^:]+):[^—]+—',
                                    rf'\1:{symbol} (line: {line_number}) —',
                                    risk_line
                                )
                enhanced_pairs.append((risk_line, fix))
            sections["__file__"] = enhanced_pairs
            section_order = [("__file__", "")]
        else:
            # 섹션 기반 파싱 시도
            extracted_sections = _extract_sections(cleaned)
            
            has_any_content = False
            for tag, _label in TAG_ORDER:
                tag_items = extracted_sections.get(tag, [])
                # 필터링 완전 제거: 모든 항목 허용
                if tag_items:
                    has_any_content = True
                    for risk, fix in _pair_item_lines(tag_items):
                        fix_clean = _format_fix_line(fix)
                        # 모든 항목 허용 (필터링 없음)
                        sections[tag].append((risk, fix_clean))
            
            # 내용이 없으면 텍스트에서 직접 찾기 시도 (필터링 완전 제거)
            if not has_any_content and cleaned:
                # [No Findings]만 있는 경우 필터링
                cleaned_lower = cleaned.strip().lower()
                if cleaned_lower in ("[no findings]", "no findings", "없음", "- 없음"):
                    has_any_content = False
                else:
                    lines = [line.strip() for line in cleaned.splitlines() if line.strip()]
                    for line in lines:
                        # [No Findings] 관련 줄은 건너뛰기
                        if line.lower() in ("[no findings]", "no findings", "없음", "- 없음"):
                            continue
                        # 필터링 완전 제거: 모든 줄 처리
                        # - 로 시작하는 모든 줄을 항목으로 처리
                        if line.startswith("-"):
                            content = line[1:].strip()
                            if content:
                                # 간단한 파싱: path:symbol — risk 형식 또는 symbol — risk 형식
                                if " — " in content or " - " in content:
                                    sep = " — " if " — " in content else " - "
                                    parts = content.split(sep, 1)
                                    if len(parts) == 2:
                                        risk = parts[1].strip()
                                        sym_part = parts[0].strip()
                                        # 파일 경로 추출
                                        if ":" in sym_part:
                                            path, sym = sym_part.split(":", 1)
                                            path = path.strip()
                                            sym = sym.strip()
                                        else:
                                            path = targets[0] if targets else "unknown"
                                            sym = sym_part
                                        
                                        # sym이 없으면 risk에서 심볼 추출 시도
                                        if not sym or sym == "항목":
                                            # def function_name( 패턴 찾기
                                            def_match = re.search(r'\bdef\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(', risk)
                                            if def_match:
                                                sym = def_match.group(1)
                                            else:
                                                # 변수명 패턴 (대문자로 시작하는 변수명)
                                                var_match = re.search(r'\b([A-Z_][A-Z0-9_]*)\s*=', risk)
                                                if var_match:
                                                    sym = var_match.group(1)
                                                else:
                                                    # 일반 변수명/함수명
                                                    word_match = re.search(r'\b([A-Za-z_][A-Za-z0-9_]{2,})\b', risk)
                                                    if word_match:
                                                        candidate = word_match.group(1)
                                                        if candidate not in ('def', 'class', 'if', 'for', 'while', 'return', 'import', 'from', 'as', 'in', 'is', 'and', 'or', 'not', 'True', 'False', 'None', '취약점', '인젝션', '노출'):
                                                            sym = candidate
                                        
                                        if risk:
                                            risk_line = f"{path}:{sym} — {risk}"
                                            sections["__file__"].append((risk_line, ""))
                                            has_any_content = True
                                else:
                                    # 구분자가 없어도 전체를 항목으로 처리
                                    path = targets[0] if targets else "unknown"
                                    # content에서 심볼 추출 시도
                                    sym = "항목"
                                    def_match = re.search(r'\bdef\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(', content)
                                    if def_match:
                                        sym = def_match.group(1)
                                    else:
                                        var_match = re.search(r'\b([A-Z_][A-Z0-9_]*)\s*=', content)
                                        if var_match:
                                            sym = var_match.group(1)
                                    risk_line = f"{path}:{sym} — {content}"
                                    sections["__file__"].append((risk_line, ""))
                                    has_any_content = True
                        # - 로 시작하지 않아도 내용이 있으면 항목으로 처리
                        elif len(line) > 5:
                            # 일반 텍스트도 항목으로 처리 (필터링 없음)
                            path = targets[0] if targets else "unknown"
                            # line에서 심볼 추출 시도
                            sym = "항목"
                            def_match = re.search(r'\bdef\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(', line)
                            if def_match:
                                sym = def_match.group(1)
                            else:
                                var_match = re.search(r'\b([A-Z_][A-Z0-9_]*)\s*=', line)
                                if var_match:
                                    sym = var_match.group(1)
                            risk_line = f"{path}:{sym} — {line}"
                            sections["__file__"].append((risk_line, ""))
                            has_any_content = True
            
            if has_any_content and sections.get("__file__"):
                section_order = [("__file__", "")]
            else:
                section_order = TAG_ORDER
        
        header = _format_header(targets)
        file_summary = header.replace("security: ", "", 1) if header.startswith("security: ") else header

        panel = render_review_panel(
            mode="security",
            file_summary=file_summary,
            section_order=section_order,
            sections=sections,
            title="Security Review",
        )
        finish_progress(handle)
        console.print(panel)
        if truncated_any:
            console.print("(참고) 일부 파일은 크기 제한으로 전체를 모두 포함하지 못했습니다.", style="dim")
        if input_context.get("meta", {}).get("limited_by_count"):
            console.print("(참고) 이번 실행에서는 파일 수 제한으로 일부 변경 파일은 상세 분석에서 제외되었습니다.", style="dim")
        if skipped:
            console.print("(참고) 일부 파일은 제외되었거나 읽기에 실패했습니다.", style="dim")
        return 0
    except Exception as exc:
        finish_progress(handle)
        console.print(f"security: 예상치 못한 오류 - {exc}")
        return 1


def _run_security_with_normal_pipeline(input_context: dict) -> int:
    """보안 모드에서 일반 모드 파이프라인을 사용하여 전체 파일 분석."""
    handle = input_context.get("_ui_handle")
    file_entries = input_context.get("files", [])
    
    if not file_entries:
        finish_progress(handle)
        console.print("security: 분석할 파일이 없습니다.")
        return 1
    
    chunking_mode = input_context.get("chunking_mode", "fast")
    
    try:
        update_status(handle, "LLM 호출 중")
        # 보안 모드 파이프라인 사용 (기본적으로 SecurityPlanner 사용)
        memory, results = run_context_review_from_entries(
            file_entries=file_entries,
            max_iterations=50,
            chunking_mode=chunking_mode,
            system_prompt=SYSTEM_PROMPT_JSON,  # 보안 모드 프롬프트
            build_user_prompt_fn=build_security_user_prompt,  # 보안 모드 user prompt 빌더
        )
        
        update_status(handle, "결과 파싱/렌더링 중")
        
        # 보안 모드 형식으로 변환
        sections = _convert_findings_to_security_sections(memory.findings, file_entries)
        targets = input_context.get("selected_paths", [])
        header = _format_header(targets)
        file_summary = header.replace("security: ", "", 1) if header.startswith("security: ") else header
        
        # 섹션 순서는 보안 모드 형식 사용
        section_order = TAG_ORDER
        
        panel = render_review_panel(
            mode="security",
            file_summary=file_summary,
            section_order=section_order,
            sections=sections,
            title="Security Review",
        )
        finish_progress(handle)
        console.print(panel)
        
        # 메타 정보 출력
        if input_context.get("meta", {}).get("limited_by_count"):
            console.print("(참고) 이번 실행에서는 파일 수 제한으로 일부 파일은 상세 분석에서 제외되었습니다.", style="dim")
        
        return 0
    except KeyboardInterrupt:
        finish_progress(handle)
        console.print("[yellow]보안 분석 모드 중단.[/yellow]")
        return 1
    except Exception as exc:
        finish_progress(handle)
        console.print(f"security: 예상치 못한 오류 - {exc}")
        import traceback
        traceback.print_exc()
        return 1


def _extract_symbol_from_file(file_path: str, line_range: tuple[int, int], message: str = "") -> tuple[str, list[str]]:
    """실제 파일에서 라인 범위에 해당하는 함수명/변수명을 추출.
    
    Args:
        file_path: 파일 경로
        line_range: (시작 라인, 끝 라인) 튜플
        message: 메시지 (키워드 추출에 사용)
    
    Returns:
        (symbol, anchors) 튜플. symbol은 함수명/변수명, anchors는 관련 토큰 리스트
    """
    import ast
    
    if not file_path or file_path == "unknown" or not line_range or line_range[0] <= 0:
        return "항목", []
    
    # 파일 경로 정리
    if not os.path.isabs(file_path):
        possible_paths = [
            file_path,
            os.path.join(os.getcwd(), file_path),
        ]
    else:
        possible_paths = [file_path]
    
    for path_str in possible_paths:
        if not os.path.exists(path_str) or not path_str.endswith((".py", ".pyi")):
            continue
        
        try:
            with open(path_str, "r", encoding="utf-8", errors="replace") as f:
                lines = f.readlines()
                source = "".join(lines)
            
            start_line, end_line = line_range
            # 라인 범위가 유효한지 확인
            if start_line > len(lines) or end_line > len(lines):
                continue
            
            # 해당 라인 범위의 코드 추출
            code_snippet = "".join(lines[start_line - 1:end_line])
            
            # AST 파싱 시도
            try:
                tree = ast.parse(source, filename=path_str)
            except SyntaxError:
                # AST 파싱 실패 시 정규식으로만 추출
                return _extract_symbol_from_code_regex(code_snippet, lines, start_line, message)
            
            # AST를 순회하며 해당 라인 범위에 포함된 함수/클래스/변수 찾기
            symbol = None
            anchors = []
            
            # 1. 함수 정의 찾기 (해당 라인 범위에 포함된 가장 가까운 함수)
            closest_func = None
            closest_distance = float('inf')
            
            for node in ast.walk(tree):
                if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                    # 함수 정의 라인이 범위 내에 있거나, 범위가 함수 내부에 있는지 확인
                    if start_line <= node.lineno <= end_line or (node.lineno <= start_line and (not hasattr(node, 'end_lineno') or node.end_lineno >= end_line)):
                        distance = abs(node.lineno - start_line)
                        if distance < closest_distance:
                            closest_distance = distance
                            closest_func = node.name
                            symbol = node.name
                            # 함수 내부의 변수/호출도 anchors로 수집
                            for child in ast.walk(node):
                                if isinstance(child, ast.Name) and isinstance(child.ctx, (ast.Load, ast.Store)):
                                    if child.id not in anchors and len(child.id) >= 2:
                                        anchors.append(child.id)
                                elif isinstance(child, ast.Attribute):
                                    if child.attr not in anchors and len(child.attr) >= 2:
                                        anchors.append(child.attr)
            
            # 2. 클래스 정의 찾기
            if not symbol:
                closest_class = None
                closest_distance = float('inf')
                for node in ast.walk(tree):
                    if isinstance(node, ast.ClassDef):
                        if start_line <= node.lineno <= end_line or (node.lineno <= start_line and (not hasattr(node, 'end_lineno') or node.end_lineno >= end_line)):
                            distance = abs(node.lineno - start_line)
                            if distance < closest_distance:
                                closest_distance = distance
                                closest_class = node.name
                                symbol = node.name
            
            # 3. 변수 할당 찾기 (해당 라인 범위에 있는 변수)
            if not symbol:
                for node in ast.walk(tree):
                    if isinstance(node, ast.Assign) and start_line <= node.lineno <= end_line:
                        for target in node.targets:
                            if isinstance(target, ast.Name):
                                symbol = target.id
                                break
                            elif isinstance(target, ast.Attribute):
                                symbol = target.attr
                                break
                        if symbol:
                            break
            
            # 4. 함수 호출이나 메서드 호출 찾기 (해당 라인 범위에 있는)
            if not symbol:
                for node in ast.walk(tree):
                    if isinstance(node, ast.Call):
                        if start_line <= node.lineno <= end_line:
                            if isinstance(node.func, ast.Name):
                                symbol = node.func.id
                                break
                            elif isinstance(node.func, ast.Attribute):
                                symbol = node.func.attr
                                break
            
            # 5. 메시지에서 키워드 추출 (보안 관련 키워드)
            if not symbol and message:
                security_keywords = [
                    'execute', 'query', 'cursor', 'fetchone', 'fetchall',
                    'check_output', 'Popen', 'system', 'call', 'run', 'exec',
                    'open', 'read', 'write', 'file', 'path',
                    'render', 'html', 'Response', 'escape',
                    'requests', 'get', 'post', 'urlopen', 'fetch',
                    'API_KEY', 'SECRET', 'PASSWORD', 'TOKEN'
                ]
                message_lower = message.lower()
                for keyword in security_keywords:
                    if keyword.lower() in message_lower:
                        # 해당 키워드가 코드 스니펫에 있는지 확인
                        if keyword in code_snippet or keyword.lower() in code_snippet.lower():
                            symbol = keyword
                            break
            
            # 6. 코드 스니펫에서 정규식으로 추출 (fallback)
            if not symbol:
                return _extract_symbol_from_code_regex(code_snippet, lines, start_line, message)
            
            # anchors가 비어있으면 코드 스니펫에서 추출
            if not anchors:
                anchors = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", code_snippet)
                anchors = [a for a in anchors if len(a) >= 2 and a not in ('def', 'class', 'if', 'for', 'while', 'return', 'import', 'from', 'as', 'in', 'is', 'and', 'or', 'not', 'True', 'False', 'None')][:3]
            
            return symbol or "항목", anchors[:3]
            
        except Exception:
            continue
    
    return "항목", []


def _extract_symbol_from_code_regex(code_snippet: str, lines: list[str], start_line: int, message: str = "") -> tuple[str, list[str]]:
    """정규식을 사용해서 코드 스니펫에서 심볼 추출 (AST 파싱 실패 시 fallback)."""
    # 1. 함수 정의 패턴
    def_match = re.search(r'\b(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(', code_snippet)
    if def_match:
        symbol = def_match.group(1)
        anchors = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", code_snippet)
        anchors = [a for a in anchors if len(a) >= 2 and a != symbol][:2]
        return symbol, [symbol] + anchors
    
    # 2. 클래스 정의 패턴
    class_match = re.search(r'\bclass\s+([A-Za-z_][A-Za-z0-9_]*)\s*[\(:]', code_snippet)
    if class_match:
        symbol = class_match.group(1)
        anchors = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", code_snippet)
        anchors = [a for a in anchors if len(a) >= 2 and a != symbol][:2]
        return symbol, [symbol] + anchors
    
    # 3. 변수 할당 패턴 (대문자로 시작하는 상수)
    const_match = re.search(r'\b([A-Z_][A-Z0-9_]*)\s*=', code_snippet)
    if const_match:
        symbol = const_match.group(1)
        anchors = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", code_snippet)
        anchors = [a for a in anchors if len(a) >= 2 and a != symbol][:2]
        return symbol, [symbol] + anchors
    
    # 4. 함수 호출 패턴
    call_match = re.search(r'\b([A-Za-z_][A-Za-z0-9_]*)\s*\(', code_snippet)
    if call_match:
        symbol = call_match.group(1)
        if symbol not in ('if', 'for', 'while', 'return', 'print', 'len', 'str', 'int', 'list', 'dict'):
            anchors = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", code_snippet)
            anchors = [a for a in anchors if len(a) >= 2 and a != symbol][:2]
            return symbol, [symbol] + anchors
    
    # 5. 메시지에서 키워드 추출
    if message:
        security_keywords = [
            'execute', 'query', 'cursor', 'fetchone', 'fetchall',
            'check_output', 'Popen', 'system', 'call', 'run', 'exec',
            'open', 'read', 'write', 'file', 'path',
            'render', 'html', 'Response', 'escape',
            'requests', 'get', 'post', 'urlopen', 'fetch',
            'API_KEY', 'SECRET', 'PASSWORD', 'TOKEN'
        ]
        message_lower = message.lower()
        for keyword in security_keywords:
            if keyword.lower() in message_lower and keyword.lower() in code_snippet.lower():
                anchors = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", code_snippet)
                anchors = [a for a in anchors if len(a) >= 2 and a != keyword][:2]
                return keyword, [keyword] + anchors
    
    # 6. 일반 식별자 추출
    identifiers = re.findall(r"\b([A-Za-z_][A-Za-z0-9_]{2,})\b", code_snippet)
    identifiers = [i for i in identifiers if i not in ('def', 'class', 'if', 'for', 'while', 'return', 'import', 'from', 'as', 'in', 'is', 'and', 'or', 'not', 'True', 'False', 'None', 'try', 'except', 'finally', 'with')]
    if identifiers:
        symbol = identifiers[0]
        anchors = identifiers[1:3] if len(identifiers) > 1 else []
        return symbol, [symbol] + anchors
    
    return "항목", []


def _convert_findings_to_security_sections(findings: list, file_entries: list) -> dict[str, list[tuple[str, str]]]:
    """일반 모드 Finding을 보안 모드 섹션 형식으로 변환 (실제 파일에서 함수명 추출)."""
    sections: dict[str, list[tuple[str, str]]] = {key: [] for key, _label in TAG_ORDER}
    
    # Finding의 subtype을 보안 모드 태그로 매핑
    subtype_to_tag = {
        "access": "Access",
        "injection": "Injection",
        "sensitive": "Sensitive",
        "crypto": "Crypto",
        "external": "External",
    }
    
    for finding in findings:
        subtype = getattr(finding, "subtype", "").lower()
        tag = subtype_to_tag.get(subtype)
        if not tag:
            # 매핑되지 않은 경우 기본값
            tag = "Access"
        
        file_path = getattr(finding, "file_path", "unknown")
        line_range = getattr(finding, "line_range", (0, 0))
        message = getattr(finding, "message", "").strip()
        min_fix = getattr(finding, "min_fix", "").strip()
        
        if not message or not min_fix:
            continue
        
        # 실제 파일에서 함수명/변수명 추출
        symbol, anchors = _extract_symbol_from_file(file_path, line_range, message)
        
        # 라인 정보 추가
        if line_range and line_range[0] > 0:
            location = f"{file_path}:{symbol} (line: {line_range[0]})"
        else:
            location = f"{file_path}:{symbol}"
        
        # anchor 추가
        if anchors:
            anchor_text = ", ".join(anchors)
            risk_line = f"{location} — {message} (anchor: {anchor_text})"
        else:
            risk_line = f"{location} — {message}"
        
        sections[tag].append((risk_line, min_fix))
    
    return sections