#!/usr/bin/env python3
"""
Ghost 미사용 이미지 탐지/삭제 스크립트

특징:
- MySQL DB에서 Ghost가 저장한 /content/images/... 경로를 실제로 추출
- 절대 URL / 상대 URL / JSON escaped URL / URL encoding 처리
- Ghost responsive image:
    /content/images/size/w600/2026/08/foo.jpg
  를
    /content/images/2026/08/foo.jpg
  와 같은 이미지 family로 취급
- Ghost 최적화 원본:
    foo_o.jpg
  를
    foo.jpg
  와 같은 family로 취급
- jpg/png/webp/avif 등 확장자가 달라도 같은 stem이면 같은 family로 취급
- DB뿐 아니라 content/themes 안의 테마 파일도 검사
- 사용 중인 family의 모든 파생 파일은 보존
- --dry / --output 지원
"""

import argparse
import html
import os
import posixpath
import re
import subprocess
import sys
from urllib.parse import unquote, urlsplit


# ============================================================
# 설정
# ============================================================

COMPOSE_DB_SERVICE = "db"
MYSQL_DATABASE = "ghost"
MYSQL_USER = "root"
MYSQL_PASSWORD_ENV = "MYSQL_ROOT_PASSWORD"

IMAGES_ROOT = "./content/images"
THEMES_ROOT = "./content/themes"
ENV_FILE = ".env"


# DB에서 검사할 Ghost 관련 테이블.
# 실제로 존재하는 테이블/컬럼만 INFORMATION_SCHEMA에서 자동 선택한다.
DB_TABLES = (
    "posts",
    "posts_meta",
    "users",
    "tags",
    "newsletters",
    "settings",
    "members",
    "tiers",
    "offers",
    "app_fields",
    "app_settings",
    "integrations",
    "integration_settings",
)

# 이미지 URL이 들어 있을 가능성이 있는 텍스트 계열 컬럼
DB_DATA_TYPES = (
    "char",
    "varchar",
    "tinytext",
    "text",
    "mediumtext",
    "longtext",
    "json",
)

# 테마에서 실제 텍스트로 볼 파일
THEME_TEXT_EXTENSIONS = {
    ".hbs",
    ".html",
    ".htm",
    ".css",
    ".scss",
    ".sass",
    ".less",
    ".js",
    ".mjs",
    ".cjs",
    ".ts",
    ".json",
    ".md",
    ".txt",
    ".xml",
    ".yaml",
    ".yml",
}

# /content/images/ 를 포함한 실제 Ghost 이미지 URL/path 탐지
IMAGE_REF_RE = re.compile(
    r"""(?i)
    (?<![A-Za-z0-9_-])
    (?:
        (?:(?:https?:)?//[^<>"'\s]+)?
    )
    /?content/images/
    [^<>"'\s]+
    """,
    re.VERBOSE,
)

# Ghost responsive image 경로
SIZE_RE = re.compile(r"^size/w\d+/", re.IGNORECASE)

# Ghost 최적화 원본 suffix
ORIGINAL_SUFFIX = "_o"


# ============================================================
# ENV
# ============================================================

def load_env_file(path: str) -> None:
    """os.environ에 없는 값만 .env에서 읽는다."""
    if not os.path.isfile(path):
        return

    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()

            if not line or line.startswith("#") or "=" not in line:
                continue

            key, _, value = line.partition("=")

            key = key.strip()
            value = value.strip()

            if (
                len(value) >= 2
                and value[0] == value[-1]
                and value[0] in ("'", '"')
            ):
                value = value[1:-1]

            os.environ.setdefault(key, value)


# ============================================================
# MYSQL
# ============================================================

def run_mysql(sql: str) -> str:
    password = os.environ.get(MYSQL_PASSWORD_ENV)

    if not password:
        sys.exit(
            f"환경변수 {MYSQL_PASSWORD_ENV} 가 설정되어 있지 않습니다."
        )

    # 기존처럼 -pPASSWORD 사용.
    # 비밀번호가 복잡한 문자열이어도 하나의 argv로 전달되므로 shell injection은 없음.
    cmd = [
        "docker",
        "compose",
        "exec",
        "-T",
        COMPOSE_DB_SERVICE,
        "mysql",
        "--batch",
        "--skip-column-names",
        "--raw",
        f"-u{MYSQL_USER}",
        f"-p{password}",
        MYSQL_DATABASE,
        "-e",
        sql,
    ]

    result = subprocess.run(
        cmd,
        capture_output=True,
        text=True,
        encoding="utf-8",
        errors="replace",
    )

    if result.returncode != 0:
        sys.exit(
            "DB 쿼리 실패:\n"
            + (result.stderr.strip() or "(stderr 없음)")
        )

    return result.stdout


def get_existing_text_columns():
    """
    Ghost DB에서 실제 존재하는 관련 테이블의 text 계열 컬럼을 검색한다.
    Ghost 버전별 schema 차이 때문에 고정 컬럼명을 직접 사용하지 않는다.
    """

    table_names = ", ".join(
        "'" + table.replace("'", "''") + "'"
        for table in DB_TABLES
    )

    type_names = ", ".join(
        "'" + data_type + "'"
        for data_type in DB_DATA_TYPES
    )

    sql = f"""
SELECT
    TABLE_NAME,
    COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME IN ({table_names})
  AND DATA_TYPE IN ({type_names})
ORDER BY TABLE_NAME, ORDINAL_POSITION;
"""

    output = run_mysql(sql)

    columns = []

    for line in output.splitlines():
        if not line.strip():
            continue

        parts = line.split("\t", 1)

        if len(parts) != 2:
            continue

        table_name, column_name = parts

        columns.append((table_name, column_name))

    return columns


def fetch_db_text() -> str:
    """
    관련 Ghost 테이블의 모든 text 계열 컬럼을 하나의 텍스트로 가져온다.

    Base64/HEX를 사용하지 않는다.
    따라서 기존 코드의 TO_BASE64() 76-character line wrapping 문제가 없다.
    """

    columns = get_existing_text_columns()

    if not columns:
        sys.exit("Ghost DB에서 검사 가능한 텍스트 컬럼을 찾지 못했습니다.")

    selects = []

    for table_name, column_name in columns:
        table = f"`{table_name.replace('`', '``')}`"
        column = f"`{column_name.replace('`', '``')}`"

        selects.append(
            f"""
SELECT CAST({column} AS CHAR)
FROM {table}
WHERE {column} IS NOT NULL
  AND {column} <> ''
""".strip()
        )

    sql = "\nUNION ALL\n".join(selects)

    return run_mysql(sql)


# ============================================================
# TEXT NORMALIZATION
# ============================================================

def normalize_text(text: str) -> str:
    """
    JSON/HTML 등에 의해 escaped 된 URL을 최대한 원래 형태로 되돌린다.
    """

    # JSON escaped slash
    text = text.replace("\\/", "/")

    # 흔히 등장하는 JSON unicode escapes
    replacements = {
        r"\u002F": "/",
        r"\u002f": "/",
        r"\u0026": "&",
        r"\u003F": "?",
        r"\u003f": "?",
        r"\u0023": "#",
    }

    for old, new in replacements.items():
        text = text.replace(old, new)

    # HTML entity
    text = html.unescape(text)

    return text


def decode_url(value: str) -> str:
    """
    URL encoding을 여러 번 decode한다.
    예:
        %2Fcontent%2Fimages%2F...
        %252Fcontent%252Fimages%252F...
    """

    previous = value

    for _ in range(3):
        current = unquote(previous)

        if current == previous:
            break

        previous = current

    return previous


# ============================================================
# IMAGE PATH NORMALIZATION
# ============================================================

def normalize_image_reference(raw: str):
    """
    DB/테마에서 추출한 이미지 URL을
    Ghost의 content/images 기준 상대경로로 변환한다.

    예:
        https://bonik.me/content/images/2026/08/a.jpg
        /content/images/2026/08/a.jpg
        content/images/2026/08/a.jpg

    모두:

        2026/08/a.jpg

    """

    value = raw.strip()

    # URL 끝에 붙을 수 있는 HTML/Markdown/JSON punctuation 제거
    value = value.rstrip(".,;:!?)]}'\"`>")

    value = normalize_text(value)
    value = decode_url(value)

    # query / fragment 제거
    value = value.split("?", 1)[0]
    value = value.split("#", 1)[0]

    # 앞뒤 공백/quotes 제거
    value = value.strip(" \t\r\n'\"")

    if not value:
        return None

    # 절대 URL / protocol-relative URL
    if value.startswith(("http://", "https://", "//")):
        try:
            path = urlsplit(value).path
        except Exception:
            return None
    else:
        path = value

    # 역슬래시 -> 슬래시
    path = path.replace("\\", "/")

    # Ghost 경로의 content/images 위치 찾기
    lower_path = path.lower()
    marker = "/content/images/"

    index = lower_path.find(marker)

    if index >= 0:
        relative = path[index + len(marker):]
    elif lower_path.startswith("content/images/"):
        relative = path[len("content/images/"):]
    else:
        return None

    relative = decode_url(relative)
    relative = relative.lstrip("/")

    # path traversal 방지
    relative = posixpath.normpath(relative)

    if relative in ("", ".", "..") or relative.startswith("../"):
        return None

    return relative


def canonical_family_from_relative(relative: str):
    """
    Ghost image의 logical family key 생성.

    다음은 모두 같은 family가 된다:

        2026/08/foo.jpg
        2026/08/foo_o.jpg
        size/w600/2026/08/foo.jpg
        size/w1200/2026/08/foo.webp
        size/w1600/2026/08/foo.avif

    -> 2026/08/foo
    """

    relative = relative.replace("\\", "/")
    relative = relative.lstrip("/")

    # Ghost responsive image variant 제거
    while True:
        match = SIZE_RE.match(relative)

        if not match:
            break

        relative = relative[match.end():]

    relative = posixpath.normpath(relative)

    if relative in ("", ".") or relative.startswith("../"):
        return None

    directory, filename = posixpath.split(relative)

    if not filename:
        return None

    stem, _ = posixpath.splitext(filename)

    if not stem:
        return None

    # Ghost optimized original: foo_o.jpg
    if stem.endswith(ORIGINAL_SUFFIX):
        stem = stem[:-len(ORIGINAL_SUFFIX)]

    if directory:
        return f"{directory}/{stem}"

    return stem


def family_from_reference(relative: str):
    return canonical_family_from_relative(relative)


def family_from_file(path: str):
    """
    실제 파일의 상대경로를 logical family로 변환.
    """

    relative = os.path.relpath(path, IMAGES_ROOT)

    # Windows에서도 동일하게 비교할 수 있도록 POSIX 형태로 변환
    relative = relative.replace(os.sep, "/")

    return canonical_family_from_relative(relative)


# ============================================================
# REFERENCE EXTRACTION
# ============================================================

def extract_references(text: str):
    """
    텍스트에서 /content/images/... 참조를 찾아
    family -> 실제 발견된 참조 URL/path 집합
    형태의 dict를 만든다.
    """

    result = {}

    text = normalize_text(text)

    for match in IMAGE_REF_RE.finditer(text):
        raw = match.group(0)

        relative = normalize_image_reference(raw)

        if not relative:
            continue

        family = family_from_reference(relative)

        if not family:
            continue

        result.setdefault(family, set()).add(relative)

    return result


def merge_references(target: dict, source: dict):
    for family, refs in source.items():
        target.setdefault(family, set()).update(refs)


# ============================================================
# THEME SCAN
# ============================================================

def scan_themes():
    """
    content/themes 아래의 텍스트 파일에서
    직접 /content/images/... 를 참조하는 경우를 찾는다.
    """

    references = {}
    scanned_files = 0

    if not os.path.isdir(THEMES_ROOT):
        return references, scanned_files

    for dirpath, dirnames, filenames in os.walk(THEMES_ROOT):
        # 불필요한 대형 디렉터리
        dirnames[:] = [
            d
            for d in dirnames
            if d not in {".git", "node_modules"}
        ]

        for filename in filenames:
            ext = os.path.splitext(filename)[1].lower()

            if ext not in THEME_TEXT_EXTENSIONS:
                continue

            path = os.path.join(dirpath, filename)

            try:
                with open(
                    path,
                    "r",
                    encoding="utf-8",
                    errors="ignore",
                ) as f:
                    text = f.read()

                scanned_files += 1

                found = extract_references(text)
                merge_references(references, found)

            except OSError:
                continue

    return references, scanned_files


# ============================================================
# FILE SCAN
# ============================================================

def scan_image_files():
    """
    content/images 전체 파일을 family별로 분류한다.
    """

    files = []

    if not os.path.isdir(IMAGES_ROOT):
        sys.exit(f"이미지 디렉터리가 없습니다: {IMAGES_ROOT}")

    for dirpath, _, filenames in os.walk(IMAGES_ROOT):
        for filename in filenames:
            path = os.path.join(dirpath, filename)

            try:
                if os.path.isfile(path) or os.path.islink(path):
                    files.append(path)
            except OSError:
                continue

    return files


def find_unused_files(used_families):
    """
    사용 중인 logical family에 포함된 모든 파일은 보존한다.

    또한 다음 디렉터리의 파일은 미사용으로 판단되어도 제외한다:
        ./content/images/icon
        ./content/images/thumbnail
    """

    all_files = scan_image_files()

    unused = []
    used = []

    excluded_dirs = {
        os.path.normpath(os.path.join(IMAGES_ROOT, "icon")),
        os.path.normpath(os.path.join(IMAGES_ROOT, "thumbnail")),
    }

    for path in all_files:
        normalized_path = os.path.normpath(path)

        # icon / thumbnail 디렉터리는 항상 보존
        if any(
            normalized_path == d or normalized_path.startswith(d + os.sep)
            for d in excluded_dirs
        ):
            used.append(path)
            continue

        family = family_from_file(path)

        if family and family in used_families:
            used.append(path)
        else:
            unused.append(path)

    # 가나다순 정렬
    unused.sort(key=lambda x: x.casefold())

    return all_files, used, unused


# ============================================================
# DELETE
# ============================================================

def delete_files(paths):
    deleted = 0

    for path in paths:
        try:
            os.remove(path)
            print(f"삭제됨: {path}")
            deleted += 1

        except OSError as e:
            print(f"삭제 실패: {path} ({e})")

    return deleted


# ============================================================
# ARGS
# ============================================================

def parse_args():
    parser = argparse.ArgumentParser(
        description="Ghost 미사용 이미지 탐지/삭제"
    )

    parser.add_argument(
        "--dry",
        action="store_true",
        help="삭제하지 않고 미사용 파일만 출력",
    )

    parser.add_argument(
        "--output",
        metavar="파일명.txt",
        help="미사용 파일 목록을 파일로 저장하고 삭제하지 않음",
    )

    parser.add_argument(
        "--no-theme-scan",
        action="store_true",
        help="content/themes 직접 참조 검사를 하지 않음",
    )

    return parser.parse_args()


# ============================================================
# MAIN
# ============================================================

def main():
    args = parse_args()

    load_env_file(ENV_FILE)

    print("[1/4] Ghost DB 검사 중...")

    db_text = fetch_db_text()
    db_refs = extract_references(db_text)

    print(f"  DB에서 발견한 이미지 family: {len(db_refs)}")

    # --------------------------------------------------------
    # Theme
    # --------------------------------------------------------

    theme_refs = {}
    scanned_theme_files = 0

    if not args.no_theme_scan:
        print("[2/4] Ghost 테마 검사 중...")

        theme_refs, scanned_theme_files = scan_themes()

        print(f"  검사한 테마 파일: {scanned_theme_files}")
        print(f"  테마에서 발견한 이미지 family: {len(theme_refs)}")
    else:
        print("[2/4] 테마 검사 생략")

    # --------------------------------------------------------
    # Merge
    # --------------------------------------------------------

    used_families = {}

    merge_references(used_families, db_refs)
    merge_references(used_families, theme_refs)

    print(f"[3/4] 총 사용 중 이미지 family: {len(used_families)}")

    # --------------------------------------------------------
    # Files
    # --------------------------------------------------------

    print("[4/4] content/images 검사 중...")

    all_files, used_files, unused_files = find_unused_files(
        used_families
    )

    print()
    print("========================================")
    print(f"전체 파일        : {len(all_files)}")
    print(f"사용 중 파일     : {len(used_files)}")
    print(f"미사용 파일      : {len(unused_files)}")
    print("========================================")

    # --------------------------------------------------------
    # No unused files
    # --------------------------------------------------------

    if not unused_files:
        print("\n미사용 파일이 없습니다.")
        return

    # --------------------------------------------------------
    # Output
    # --------------------------------------------------------

    if args.output:
        with open(
            args.output,
            "w",
            encoding="utf-8",
        ) as f:
            for path in unused_files:
                f.write(path + "\n")

        print(
            f"\n-> {args.output} 에 저장했습니다."
            " 삭제는 실행하지 않았습니다."
        )

        return

    # --------------------------------------------------------
    # Dry run
    # --------------------------------------------------------

    print("\n미사용 파일 목록:")

    for path in unused_files:
        print(path)

    if args.dry:
        print("\n[DRY RUN] 삭제하지 않았습니다.")
        return

    # --------------------------------------------------------
    # Confirmation
    # --------------------------------------------------------

    answer = input(
        f"\n위 {len(unused_files)}개 파일을 삭제하시겠습니까? [y/N] "
    ).strip().lower()

    if answer != "y":
        print("삭제를 취소했습니다.")
        return

    deleted = delete_files(unused_files)

    print()
    print(f"삭제 완료: {deleted}개")
    print(f"삭제 실패: {len(unused_files) - deleted}개")


if __name__ == "__main__":
    main()
