#!/usr/bin/env python3
import os
import sys
import pathlib
import base64
from enum import Enum, unique, auto
from dataclasses import dataclass
import re
import subprocess
import urllib.error
import urllib.parse
import urllib.request
from html import escape, unescape
import yaml

import click

try:
    from latex2mathml.converter import convert as latex_to_mathml
except ImportError:
    latex_to_mathml = None

CFG_PATH = "./config.yml"
STATE_PATH = "./state.yml"

TC_DIR = "./_testcases"

TEMPLATES_DIR = "./templates"

SRC_SPACE_DIR = "./space"

STORAGE_DIR = "./storage"

BUILD_DIR = "./build"


@unique
class Language(Enum):
    """
    실행 가능한 언어(Language)를 정의하는 클래스입니다. 각 언어는 고유 문자열 값을 가짐.
    - Python: py
    - C: c
    - C++: cpp
    - Rust: rs
    - Kotlin: kt
    - Lua: lua
    """
    PYTHON = "py"
    C = "c"
    CPP = "cpp"
    RUST = "rs"
    KOTLIN = "kt"
    LUA = "lua"

    UNDEFINED = "undefined"

    @property
    def build_command(self):
        """
        각 언어에 대한 빌드 명령어.
        """
        return {
            Language.PYTHON: [
                "python",
                "./make.py",
                "build",
            ],
            Language.C: [
                "make",
                "build",
            ],
            Language.CPP: [
                "make",
                "build",
            ],
            Language.RUST: [
                "make",
                "build",
            ],
            Language.KOTLIN: [
                "./gradlew",
                "jar",
            ],
            Language.LUA: [
                "make",
                "build",
            ],
        }[self]

    @property
    def working_dir(self):
        """
        각 언어의 작업 디렉토리
        """
        return f"{SRC_SPACE_DIR}/src-{self.value}"

    @property
    def build_executable(self):
        """
        각 언어의 빌드 후 생성되는 실행 파일의 이름.
        """
        return {
            Language.PYTHON: "py.pyc",
            Language.C: "c.out",
            Language.CPP: "cpp.out",
            Language.RUST: "rs.out",
            Language.KOTLIN: "kt.jar",
            Language.LUA: "lua.luac",
        }[self]
    
    @property
    def executable_command(self):
        """
        각 언어에 대한 실행 명령어. 
        """
        return {
            Language.PYTHON: ["python", f"{BUILD_DIR}/{self.build_executable}"],
            Language.C: [f"{BUILD_DIR}/{self.build_executable}"],
            Language.CPP: [f"{BUILD_DIR}/{self.build_executable}"],
            Language.RUST: [f"{BUILD_DIR}/{self.build_executable}"],
            Language.KOTLIN: ["java", "-jar", f"{BUILD_DIR}/{self.build_executable}"],
            Language.LUA: ["lua", f"{BUILD_DIR}/{self.build_executable}"],
        }[self]

    @staticmethod
    def convert_ext(ext: str):
        """
        확장자를 언어 Enum으로 변환하는 메소드.
        만약 확장자가 정의된 언어에 해당하지 않으면 UNDEFINED를 반환.
        """
        match ext:
            case "py":
                return Language.PYTHON
            case "c":
                return Language.C
            case "cpp":
                return Language.CPP
            case "rs":
                return Language.RUST
            case "kt":
                return Language.KOTLIN
            case "lua":
                return Language.LUA
            case _:
                return Language.UNDEFINED

    @staticmethod
    def convert_name(format_name: str):
        """
        다양한 언어 형식 이름을 언어 Enum으로 변환하는 메소드.
        convert_ext의 Super Set임.
        """
        if Language.convert_ext(format_name) is not Language.UNDEFINED:
            return Language.convert_ext(format_name)
        match format_name.lower():
            case "py" | "py3" | "python" | "python3":
                return Language.PYTHON
            case "c":
                return Language.C
            case "cpp" | "c++":
                return Language.CPP
            case "rust" | "rs":
                return Language.RUST
            case "kt" | "kotlin":
                return Language.KOTLIN
            case "lua":
                return Language.LUA
            case _:
                return Language.UNDEFINED


def natural_sort_key(s: str):
    """Natural sort key: splits string into text/number chunks for proper ordering."""
    return [int(c) if c.isdigit() else c.lower() for c in re.split(r"(\d+)", s)]

def parse_range_string(range_str: str) -> list[int]:
    """
    범위 문자열을 정수 리스트로 변환.
    * ..N: 1부터 N까지
    * M..N: M부터 N까지
    * N: 단일 숫자
    """
    if range_str.startswith(".."):
        end_str = range_str[2:]
        if not end_str.isdigit():
            raise ValueError("Invalid range format")
        return list(range(1, int(end_str) + 1))

    parts = range_str.split("..")
    if len(parts) == 1:
        if not parts[0].isdigit():
            raise ValueError("Invalid range format")
        return [int(parts[0])]
    elif len(parts) == 2:
        if not parts[0].isdigit() or not parts[1].isdigit():
            raise ValueError("Invalid range format")
        return list(range(int(parts[0]), int(parts[1]) + 1))
    else:
        raise ValueError("Invalid range format")


def parse_range_string_list(str_list) -> list[int]:
    """
    여러 범위 문자열을 정수 리스트로 변환하는 함수; 이때 중복은 제거함.
        * e.g. ["1..3", "5", "7..9"] -> [1, 2, 3, 5, 7, 8, 9]
    """
    result = set()
    for s in str_list:
        result.update(parse_range_string(s))
    return list(result)


def parse_fetchprob_target(target: str) -> tuple[str, str | None]:
    """
    fetchprob target parser.
    - zeta/<id>: single mode
    - zeta: batch mode
    """
    parts = target.split("/", 1)
    location = parts[0].strip()

    if location != "zeta":
        raise click.UsageError("fetchprob target must start with 'zeta'")

    if len(parts) == 1:
        return location, None

    problem_id = parts[1].strip()
    if not problem_id.isdigit():
        raise click.UsageError("problem id must be numeric (e.g. zeta/2447)")

    return location, problem_id


def extract_problem_id_from_stem(stem: str) -> str | None:
    """
    Extract BOJ numeric id from file stem.
    Accepted forms: <id>, <id>_<suffix>, <id>-<suffix>
    """
    m = re.match(r"^(\d+)(?:[_-].*)?$", stem)
    return m.group(1) if m else None


def collect_zeta_problem_ids() -> list[str]:
    """
    Collect problem ids from storage/zeta/* and storage/zeta/*/completed.
    """
    zeta_dir = pathlib.Path(STORAGE_DIR) / "zeta"
    if not zeta_dir.is_dir():
        raise click.ClickException(f"Storage location '{zeta_dir}' not found")

    ids: set[str] = set()
    for lang_dir in sorted(zeta_dir.iterdir()):
        if not lang_dir.is_dir() or lang_dir.name.startswith("_"):
            continue

        for f in lang_dir.iterdir():
            if f.is_file():
                problem_id = extract_problem_id_from_stem(f.stem)
                if problem_id:
                    ids.add(problem_id)

        completed_dir = lang_dir / "completed"
        if completed_dir.is_dir():
            for f in completed_dir.iterdir():
                if f.is_file():
                    problem_id = extract_problem_id_from_stem(f.stem)
                    if problem_id:
                        ids.add(problem_id)

    return sorted(ids, key=int)


def fetch_boj_problem_html(problem_id: str, timeout: int = 10) -> str:
    """
    Download BOJ problem page raw HTML.
    """
    url = f"https://www.acmicpc.net/problem/{problem_id}"
    req = urllib.request.Request(
        url,
        headers={
            "User-Agent": (
                "Mozilla/5.0 (X11; Linux x86_64) "
                "AppleWebKit/537.36 (KHTML, like Gecko) "
                "Chrome/123.0.0.0 Safari/537.36"
            )
        },
    )

    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            status = getattr(resp, "status", 200)
            if status != 200:
                raise click.ClickException(
                    f"failed to fetch problem {problem_id}: HTTP {status}"
                )
            return resp.read().decode("utf-8", errors="replace")
    except urllib.error.HTTPError as e:
        raise click.ClickException(
            f"failed to fetch problem {problem_id}: HTTP {e.code}"
        ) from e
    except urllib.error.URLError as e:
        raise click.ClickException(
            f"network error while fetching problem {problem_id}: {e.reason}"
        ) from e


def _problem_static_html_path(problem_id: str) -> pathlib.Path:
    return pathlib.Path(STORAGE_DIR) / "zeta" / "_static" / f"{problem_id}.html"


def _problem_static_assets_dir(problem_id: str) -> pathlib.Path:
    return pathlib.Path(STORAGE_DIR) / "zeta" / "_static" / "assets" / problem_id


def _guess_image_mime(src_url: str, content_type: str | None) -> str:
    if content_type:
        mime = content_type.split(";", 1)[0].strip().lower()
        if mime.startswith("image/"):
            return mime

    parsed = urllib.parse.urlparse(src_url)
    ext = pathlib.Path(parsed.path).suffix.lower()
    ext_to_mime = {
        ".jpg": "image/jpeg",
        ".jpeg": "image/jpeg",
        ".png": "image/png",
        ".gif": "image/gif",
        ".webp": "image/webp",
        ".svg": "image/svg+xml",
        ".bmp": "image/bmp",
        ".ico": "image/x-icon",
    }
    return ext_to_mime.get(ext, "image/png")


def _download_image_for_offline(problem_id: str, src_url: str, seq: int, force: bool) -> str | None:
    req = urllib.request.Request(
        src_url,
        headers={
            "User-Agent": (
                "Mozilla/5.0 (X11; Linux x86_64) "
                "AppleWebKit/537.36 (KHTML, like Gecko) "
                "Chrome/123.0.0.0 Safari/537.36"
            ),
            "Referer": f"https://www.acmicpc.net/problem/{problem_id}",
        },
    )

    try:
        with urllib.request.urlopen(req, timeout=10) as resp:
            status = getattr(resp, "status", 200)
            if status != 200:
                return None
            content_type = resp.headers.get("Content-Type")
            image_bytes = resp.read()
    except (urllib.error.HTTPError, urllib.error.URLError):
        return None

    mime = _guess_image_mime(src_url, content_type)
    encoded = base64.b64encode(image_bytes).decode("ascii")
    return f"data:{mime};base64,{encoded}"


def _localize_images_in_html(problem_id: str, html_fragment: str, force: bool) -> str:
    base_url = f"https://www.acmicpc.net/problem/{problem_id}"
    counter = {"i": 0}
    cache: dict[str, str] = {}

    pattern = re.compile(
        r'(<img\b[^>]*?\bsrc\s*=\s*)(["\']?)([^"\'>\s]+)(["\']?)',
        flags=re.IGNORECASE,
    )

    def repl(m: re.Match) -> str:
        prefix = m.group(1)
        q1 = m.group(2)
        src = m.group(3)

        if src.startswith("data:"):
            return m.group(0)

        abs_url = urllib.parse.urljoin(base_url, src)
        if abs_url in cache:
            local_src = cache[abs_url]
            quote = q1 if q1 else '"'
            return f"{prefix}{quote}{local_src}{quote}"

        counter["i"] += 1
        local_src = _download_image_for_offline(problem_id, abs_url, counter["i"], force=force)
        if not local_src:
            return m.group(0)

        cache[abs_url] = local_src
        quote = q1 if q1 else '"'
        return f"{prefix}{quote}{local_src}{quote}"

    return pattern.sub(repl, html_fragment)


def _extract_html_by_id(raw_html: str, tag: str, element_id: str) -> str | None:
    pattern = rf"<{tag}[^>]*id=\"{re.escape(element_id)}\"[^>]*>(.*?)</{tag}>"
    m = re.search(pattern, raw_html, flags=re.DOTALL | re.IGNORECASE)
    if not m:
        return None
    return m.group(1).strip()


def _strip_tags(html_text: str) -> str:
    text = re.sub(r"<[^>]+>", "", html_text, flags=re.DOTALL)
    return " ".join(text.split())


def _render_math_expressions(html_fragment: str) -> str:
    """
    Convert TeX math delimiters to MathML for offline rendering.
    - Inline: $...$
    - Block: $$...$$
    """
    if latex_to_mathml is None:
        return html_fragment

    protected_blocks: list[str] = []

    def protect(m: re.Match) -> str:
        protected_blocks.append(m.group(0))
        return f"@@PROTECTED_{len(protected_blocks) - 1}@@"

    # Do not touch code/pre blocks.
    temp = re.sub(
        r"<(pre|code)\b[^>]*>.*?</\1>",
        protect,
        html_fragment,
        flags=re.DOTALL | re.IGNORECASE,
    )

    def repl_block(m: re.Match) -> str:
        expr = unescape(m.group(1).strip())
        if not expr:
            return m.group(0)
        try:
            mathml = latex_to_mathml(expr)
            return f'<div class="math-block">{mathml}</div>'
        except Exception:
            return m.group(0)

    def repl_inline(m: re.Match) -> str:
        expr = unescape(m.group(1).strip())
        if not expr:
            return m.group(0)
        try:
            mathml = latex_to_mathml(expr)
            return f'<span class="math-inline">{mathml}</span>'
        except Exception:
            return m.group(0)

    temp = re.sub(r"\$\$(.+?)\$\$", repl_block, temp, flags=re.DOTALL)
    temp = re.sub(r"(?<!\$)\$(?!\$)(.+?)(?<!\$)\$(?!\$)", repl_inline, temp, flags=re.DOTALL)

    for i, block in enumerate(protected_blocks):
        temp = temp.replace(f"@@PROTECTED_{i}@@", block)

    return temp


def make_offline_problem_html(problem_id: str, raw_html: str, force: bool) -> str:
    """
    Build a self-contained offline-friendly HTML page from BOJ raw HTML.
    """
    title = _extract_html_by_id(raw_html, "span", "problem_title")
    if not title:
        title = f"BOJ {problem_id}"

    blocks: list[str] = []
    core_specs = [
        ("problem_description", "문제"),
        ("problem_input", "입력"),
        ("problem_output", "출력"),
        ("problem_limit", "제한"),
        ("problem_hint", "힌트"),
    ]

    for content_id, fallback_label in core_specs:
        content = _extract_html_by_id(raw_html, "div", content_id)
        if not content or not content.strip():
            continue

        localized_content = _localize_images_in_html(
            problem_id,
            content,
            force=force,
        )
        localized_content = _render_math_expressions(localized_content)

        blocks.append(
            "\n".join(
                [
                    "<article class=\"section\">",
                    f"<h2>{fallback_label}</h2>",
                    f"{localized_content}",
                    "</article>",
                ]
            )
        )

    for sample_type, sample_label in (("sampleinput", "예제 입력"), ("sampleoutput", "예제 출력")):
        sample_pattern = rf"<section[^>]*id=\"{sample_type}(\d+)\"[^>]*>(.*?)</section>"
        sample_matches = list(
            re.finditer(sample_pattern, raw_html, flags=re.DOTALL | re.IGNORECASE)
        )
        sample_matches.sort(key=lambda m: int(m.group(1)))

        for m in sample_matches:
            idx = m.group(1)
            section_html = m.group(2)
            pre_match = re.search(
                r"(<pre[^>]*>.*?</pre>)",
                section_html,
                flags=re.DOTALL | re.IGNORECASE,
            )
            if not pre_match:
                continue

            pre_html = _localize_images_in_html(
                problem_id,
                pre_match.group(1),
                force=force,
            )

            h2_match = re.search(
                r"<h2[^>]*>(.*?)</h2>",
                section_html,
                flags=re.DOTALL | re.IGNORECASE,
            )
            if h2_match:
                h2 = _strip_tags(h2_match.group(1))
            else:
                h2 = f"{sample_label} {idx}"

            blocks.append(
                "\n".join(
                    [
                        "<article class=\"section\">",
                        f"<h2>{h2}</h2>",
                        pre_html,
                        "</article>",
                    ]
                )
            )

    if not blocks:
        body_fallback = (
            "<article class=\"section\">"
            "<h2>원본 페이지</h2>"
            "<p>문제 본문 파싱에 실패하여 원본 HTML을 포함합니다.</p>"
            f"<pre>{escape(raw_html[:100000])}</pre>"
            "</article>"
        )
        blocks.append(body_fallback)

    source_url = f"https://www.acmicpc.net/problem/{problem_id}"
    content_html = "\n".join(blocks)

    return f"""<!DOCTYPE html>
<html lang=\"ko\">
<head>
    <meta charset=\"UTF-8\" />
    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />
    <title>BOJ {problem_id} - Offline</title>
    <style>
        :root {{
            --bg: #fafaf8;
            --paper: #ffffff;
            --ink: #1e1f24;
            --muted: #6a6d75;
            --line: #d8dce3;
            --accent: #0d6e6e;
            --code-bg: #f4f6fb;
        }}
        * {{ box-sizing: border-box; }}
        body {{
            margin: 0;
            background:
                radial-gradient(circle at 15% 0%, #f0efe9 0%, transparent 42%),
                radial-gradient(circle at 85% 20%, #e7f1f2 0%, transparent 38%),
                var(--bg);
            color: var(--ink);
            font-family: "Noto Sans KR", "Pretendard", "Apple SD Gothic Neo", sans-serif;
            line-height: 1.65;
        }}
        main {{
            max-width: 980px;
            margin: 0 auto;
            padding: 24px 16px 56px;
        }}
        .header {{
            background: var(--paper);
            border: 1px solid var(--line);
            border-radius: 14px;
            padding: 18px 20px;
            margin-bottom: 18px;
        }}
        .header h1 {{ margin: 0 0 6px; font-size: 1.5rem; }}
        .header p {{ margin: 0; color: var(--muted); font-size: 0.95rem; }}
        .header a {{ color: var(--accent); text-decoration: none; }}
        .section {{
            background: var(--paper);
            border: 1px solid var(--line);
            border-radius: 14px;
            padding: 16px 18px;
            margin-bottom: 14px;
            overflow-x: auto;
        }}
        h2 {{
            margin: 0 0 10px;
            font-size: 1.05rem;
            color: var(--accent);
            border-bottom: 1px solid var(--line);
            padding-bottom: 8px;
        }}
        pre, code {{
            font-family: "JetBrains Mono", "Fira Code", monospace;
            background: var(--code-bg);
        }}
        pre {{
            padding: 12px;
            border-radius: 10px;
            border: 1px solid #e7ebf2;
            overflow: auto;
        }}
        blockquote {{
            margin: 14px 0;
            padding: 16px 16px 14px 22px;
            border-left: 4px solid var(--accent);
            border-radius: 10px;
            background: linear-gradient(90deg, #eef8f8 0%, #f9fdfd 100%);
            color: #24313a;
            font-weight: 600;
            position: relative;
        }}
        blockquote::before {{
            content: "“";
            position: absolute;
            left: 8px;
            top: 2px;
            font-size: 1.35rem;
            line-height: 1;
            color: #0b5f5f;
            opacity: 0.7;
        }}
        blockquote > :first-child {{ margin-top: 0; }}
        blockquote > :last-child {{ margin-bottom: 0; }}
        q {{
            color: #114f50;
            font-weight: 700;
            background: #edf8f8;
            border-radius: 6px;
            padding: 0 4px;
        }}
        .math-inline math {{
            font-size: 1em;
            vertical-align: middle;
        }}
        .math-block {{
            margin: 10px 0;
            padding: 8px 10px;
            overflow-x: auto;
            background: #f8fbff;
            border: 1px solid #e2ecf8;
            border-radius: 8px;
        }}
        .math-block math {{
            font-size: 1.04em;
            display: block;
        }}
        table {{ border-collapse: collapse; width: 100%; }}
        th, td {{ border: 1px solid var(--line); padding: 6px 8px; }}
        img {{ max-width: 100%; height: auto; }}
    </style>
</head>
<body>
    <main>
        <header class=\"header\">
            <h1>{title}</h1>
        </header>
        {content_html}
    </main>
</body>
</html>
"""


def save_problem_html(problem_id: str, html: str, force: bool) -> str:
    """
    Save html to storage/zeta/_static/<id>.html
    Return: fetched | skipped
    """
    static_dir = pathlib.Path(STORAGE_DIR) / "zeta" / "_static"
    static_dir.mkdir(parents=True, exist_ok=True)

    dest = _problem_static_html_path(problem_id)
    if dest.exists() and not force:
        return "skipped"

    dest.write_text(html, encoding="utf-8")
    return "fetched"


@click.group()
def cli():
    pass


@click.command(name="run")
@click.option("--from", "from_", type=str, required=True)
@click.option("--target", "-t", default=["1"], multiple=True)
@click.option("--verbose/--no-verbose", "-v/-nv", default=True)
def run(from_: str, target: str, verbose: bool):
    """
    지정된 언어로 빌드 및 실행하는 명령어
    """
    # Language 확인
    from_language: Language = Language.convert_name(from_)

    if from_language is Language.UNDEFINED:
        raise click.ClickException("Undefined Language Exception")
    try:
        with open("state.yml", "r", encoding="utf-8") as f:
            state = yaml.safe_load(f)
    except FileNotFoundError as e:
        raise click.ClickException(e)
    except yaml.YAMLError as e:
        raise click.ClickException(e)

    if "space" not in state:
        raise click.ClickException("State(state.yml) Exception")
    elif not state["space"]:
        click.secho("state.space has no member", fg="yellow", bold=True)
        state["space"] = {}

    lang_space_state: dict | None
    if from_language.value not in state["space"]:
        lang_space_state = None
    else:
        lang_space_state = state["space"][from_language.value]

    if lang_space_state is None or not lang_space_state:
        raise click.ClickException("Run Exception: There are no pre-defined state")

    # 테케 분석
    try:
        tcs: list[int] = parse_range_string_list(target)
    except ValueError as e:
        raise click.ClickException(e)

    # build

    try:
        s = subprocess.run(
            from_language.build_command,
            check=True,
            capture_output=True,
            text=True,
            cwd=from_language.working_dir,
        )
        print(s.stderr)

    except subprocess.CalledProcessError as e:
        click.echo(">>>>>> [Error while BUILD process] >>>>>>")
        raise click.ClickException(e.stderr)

    for tc in tcs:
        if not (
            os.path.isfile(f"{TC_DIR}/{tc}.in") and os.path.isfile(f"{TC_DIR}/{tc}.out")
        ):
            click.echo(f"{tc:02d}: {None}")
        else:
            f_in_path = f"{TC_DIR}/{tc}.in"
            f_out_path = f"{TC_DIR}/{tc}.out"
            with open(f_in_path, "r", encoding="utf-8") as f_in:
                try:
                    stdout = subprocess.run(
                        from_language.executable_command,
                        check=True,
                        capture_output=True,
                        text=True,
                        stdin=f_in,
                    ).stdout
                except subprocess.CalledProcessError as e:
                    click.echo(">>>>>> [Error while RUNNING process] >>>>>>")
                    click.echo(f"{e.stderr}")
                    click.echo(f"returncode: {e.returncode}")
                    stdout = ""
                    continue
            flag: bool = False
            with open(f_out_path, "r", encoding="utf-8") as f_out:
                flag = stdout.strip() == f_out.read().strip()
            click.echo(f"{tc:02d}: {flag}")

            if verbose:
                click.echo("===" * 3)
                click.echo(stdout)
                click.echo("===" * 3)


@click.command(name="load")
@click.argument("file", type=str, nargs=1, required=True)
@click.option("--to", type=str, default="")  # language value
@click.option("--from", "from_", type=str, required=True)
@click.option("--force", "-f", is_flag=True)
def load(file: str, to: str, from_: str, force: bool):
    """
    파일을 각 언어의 src-space로 이동시키는 명령어.
    이 명령어는 다음 과정을 수행함.

    """

    # 파일의 src-space가 어딘지 확인
    to_language: Language
    file_name: str
    file_sep_ext = file.split(".")
    if len(file_sep_ext) == 1:
        file_name = file_sep_ext[0]
        to_language = Language.convert_name(to)  # if blank, undefined
    elif len(file_sep_ext) == 2:
        file_name = file_sep_ext[0]
        to_language = Language.convert_name(file_sep_ext[1])
    else:
        file_name = ""
        to_language = Language.UNDEFINED

    if to_language is Language.UNDEFINED:
        raise click.UsageError("LoadError: `to` or `file` argument is wrong")

    try:
        with open(STATE_PATH, "r", encoding="utf-8") as f_state:
            state = yaml.safe_load(f_state)
    except FileNotFoundError as e:
        raise click.ClickException(e)
    except yaml.YAMLError as e:
        raise click.ClickException(e)

    if "space" not in state:
        raise click.ClickException("state.yml is wrong")
    elif not state["space"]:
        click.secho("state.space has no member", fg="yellow", bold=True)
        state["space"] = {}

    lang_space_state: dict | None
    if to_language.value not in state["space"]:
        lang_space_state = None
    else:
        lang_space_state = state["space"][to_language.value]

    if not force:
        if lang_space_state is None or not lang_space_state:
            pass
        elif lang_space_state["file"] is None:
            pass
        else:
            raise click.ClickException(
                f"File {lang_space_state['file']}.{to_language.value} already in source space {to_language.value}"
            )

    # 파일이 존재하는지 확인
    # uncompleted
    file_is_exist: bool = False
    file_is_completed: bool = False
    file_loc: str = None
    if os.path.isfile(
        f"{STORAGE_DIR}/{from_}/{to_language.value}/{file_name}.{to_language.value}"
    ):
        file_is_exist = True
        file_is_completed = False
        file_loc = (
            f"{STORAGE_DIR}/{from_}/{to_language.value}/{file_name}.{to_language.value}"
        )
    elif os.path.isfile(
        f"{STORAGE_DIR}/{from_}/{to_language.value}/completed/{file_name}.{to_language.value}"
    ):
        file_is_exist = True
        file_is_completed = True
        file_loc = f"{STORAGE_DIR}/{from_}/{to_language.value}/completed/{file_name}.{to_language.value}"
    # 존재하면 move
    # 존재하지 않으면 빈 파일(또는 템플릿 파일)을 생성하고
    # state에 기록함
    if file_is_exist:
        with (
            open(file_loc, "rb") as f_src,
            open(
                f"{SRC_SPACE_DIR}/src-{to_language.value}/src/main.{to_language.value}",
                "wb",
            ) as f_dst,
        ):
            f_dst.write(f_src.read())
        os.remove(file_loc)
    else:
        if TEMPLATES_DIR and os.path.isfile(
            f"{TEMPLATES_DIR}/template.{to_language.value}"
        ):
            with (
                open(f"{TEMPLATES_DIR}/template.{to_language.value}", "rb") as f_src,
                open(
                    f"{SRC_SPACE_DIR}/src-{to_language.value}/src/main.{to_language.value}",
                    "wb",
                ) as f_dst,
            ):
                f_dst.write(f_src.read())
        else:
            with open(
                f"{SRC_SPACE_DIR}/src-{to_language.value}/src/main.{to_language.value}",
                "wb",
            ) as f_dst:
                pass

    with open("state.yml", "w", encoding="utf-8") as f:
        if lang_space_state is None:
            state["space"][to_language.value] = {}
        state["space"][to_language.value]["file"] = file_name
        state["space"][to_language.value]["location"] = from_
        state["space"][to_language.value]["is_completed"] = file_is_completed

        yaml.safe_dump(state, f)
    if file_is_exist:
        click.echo(
            f"File {file_name}.{to_language.value} from {from_}{'/completed' if file_is_completed else ''} is successfully loaded"
        )
    else:
        click.echo(f"File {file_name}.{to_language.value} is successfully created")


@click.command(name="export")
@click.option("--from", "from_", type=str, required=True)
@click.option(
    "--completed/--no-completed", "-c/-nc", is_flag=True, flag_value=False, default=None
)
@click.option("--copy", is_flag=True, help="is copy from state or move")
def export(from_: str, completed: bool, copy: bool):
    # Language 알기
    from_language = Language.convert_name(from_)

    if from_language is Language.UNDEFINED:
        raise click.ClickException("Undefined Language Exception")
    try:
        with open("state.yml", "r", encoding="utf-8") as f:
            state = yaml.safe_load(f)
    except FileNotFoundError as e:
        raise click.ClickException(e)
    except yaml.YAMLError as e:
        raise click.ClickException(e)

    if "space" not in state:
        raise click.ClickException("State(state.yml) Exception")
    elif not state["space"]:
        click.secho("state.space has no member", fg="yellow", bold=True)
        state["space"] = {}

    lang_space_state: dict | None
    if from_language.value not in state["space"]:
        lang_space_state = None
    else:
        lang_space_state = state["space"][from_language.value]

    if lang_space_state is None or not lang_space_state:
        raise click.ClickException("Export Exception: There are no pre-defined state")

    s_file = lang_space_state["file"]
    s_loc = lang_space_state["location"]
    s_is_completed = lang_space_state["is_completed"]

    if not s_file:
        raise click.ClickException(
            f"Export Exception: There is no file in {from_language.value} space"
        )

    if not s_loc:
        raise click.ClickException(f"Export Exception: Location {s_loc} is not defined")

    if completed:
        s_is_completed = True

    dest_path: str = f"{STORAGE_DIR}/{s_loc}/{from_language.value}{'/completed' if s_is_completed else ''}/{s_file}.{from_language.value}"
    source_path: str = (
        f"{SRC_SPACE_DIR}/src-{from_language.value}/src/main.{from_language.value}"
    )

    with open(source_path, "rb") as f_src, open(dest_path, "wb") as f_dst:
        f_dst.write(f_src.read())

    click.echo(f"File {s_file}.{from_language.value} successfully ")

    if not copy:
        with open("state.yml", "w", encoding="utf-8") as f:
            state["space"][from_language.value] = {}
            yaml.safe_dump(state, f)

        os.remove(source_path)


@click.command(name="state")
def state():
    try:
        with open(STATE_PATH, "r", encoding="utf-8") as f_state:
            state = yaml.safe_load(f_state)
    except FileNotFoundError as e:
        raise click.ClickException(e)
    except yaml.YAMLError as e:
        raise click.ClickException(e)

    if "space" not in state:
        raise click.ClickException("state.yml is wrong")
    elif not state["space"]:
        click.secho("state.space has no member", fg="yellow", bold=True)
        state["space"] = {}

    active = sum(1 for v in state["space"].values() if v)
    total = len(state["space"])
    click.secho(f"Space: {active}/{total} active", fg="cyan", bold=True)
    click.echo()

    for k, v in state["space"].items():
        click.secho(f"  {k}/", fg="yellow")
        if not v:
            click.echo(f"    {click.style('—', fg='bright_black')} empty")
        else:
            status = (
                click.style("✓", fg="green")
                if v["is_completed"]
                else click.style("○", fg="white")
            )
            click.echo(f"    {status} {v['file']}.{k} @ {v['location']}")


@click.command(name="init")
@click.argument("count", type=int)
def init(count: int):
    """
    init testcases with initial count and state.yml and config.yml
    """
    if count < 1:
        raise click.UsageError("var count should be greater than 1.")

    if not os.path.exists("./" + TC_DIR):
        os.mkdir(TC_DIR)
    for i in range(1, count + 1):
        default_in_path = "./" + TC_DIR + f"/{i}.in"
        default_out_path = "./" + TC_DIR + f"/{i}.out"
        if not os.path.exists(default_in_path):
            with open(default_in_path, "w", encoding="utf-8") as _:
                pass
        if not os.path.exists(default_out_path):
            with open(default_out_path, "w", encoding="utf-8") as _:
                pass

    click.echo(f"1. Testcases was made with count {count} successfully!")

    if not os.path.isfile("./state.yml"):
        click.echo("2-1. state.yml does not exist, creating...")
        with open("state.yml", "w", encoding="utf-8") as f:
            f.write("space:\n")
        click.echo("2-1. state.yml is created successfully!")
    else:
        click.echo("2-1. state.yml exists")
    if not os.path.isfile("./config.yml"):
        click.echo("2-2. config.yml does not exist, creating...")
        with open("config.yml", "w", encoding="utf-8") as f:
            f.write("space:\n")
        click.echo("2-2. config.yml is created successfully!")
    else:
        click.echo("2-2. config.yml exists")


@click.command(name="show")
@click.argument("filter", type=str, default=None, required=False)
@click.option(
    "--completed/--no-completed",
    "-c/-nc",
    default=False,
    help="Filter by completed status (default: uncompleted only)",
)
@click.option(
    "--all",
    "show_all",
    is_flag=True,
    default=False,
    help="Show all problems regardless of completed status",
)
def show(filter: str | None, completed: bool, show_all: bool):
    """
    Show all stored problems in storage directory.

    \b
    FILTER format (optional):
      location        e.g. zeta
      location/lang   e.g. zeta/rs
      /lang           e.g. /py
    """
    location = None
    lang = None
    if filter:
        parts = filter.split("/", 1)
        if len(parts) == 2:
            location = parts[0] or None
            lang = parts[1] or None
        else:
            location = parts[0]

    storage = pathlib.Path(STORAGE_DIR)
    if not storage.is_dir():
        raise click.ClickException(f"Storage directory '{STORAGE_DIR}' not found")

    # Collect all files
    entries = []  # (location, language, filename, is_completed)

    for loc_dir in sorted(storage.iterdir()):
        if not loc_dir.is_dir():
            continue
        loc_name = loc_dir.name

        if location and loc_name != location:
            continue

        for lang_dir in sorted(loc_dir.iterdir()):
            if not lang_dir.is_dir():
                continue
            lang_name = lang_dir.name

            if (
                lang
                and lang_name != Language.convert_name(lang).value
                and lang_name != lang
            ):
                continue

            # uncompleted files
            for f in sorted(lang_dir.iterdir(), key=lambda p: natural_sort_key(p.stem)):
                if f.is_file():
                    entries.append((loc_name, lang_name, f.stem, False))

            # completed files
            completed_dir = lang_dir / "completed"
            if completed_dir.is_dir():
                for f in sorted(
                    completed_dir.iterdir(), key=lambda p: natural_sort_key(p.stem)
                ):
                    if f.is_file():
                        entries.append((loc_name, lang_name, f.stem, True))

    # Filter by completed status
    if not show_all:
        entries = [e for e in entries if e[3] == completed]

    if not entries:
        click.echo("No problems found.")
        return

    # Display
    total = len(entries)
    completed_count = sum(1 for e in entries if e[3])
    uncompleted_count = total - completed_count

    click.secho(
        f"Total: {total} (completed: {completed_count}, uncompleted: {uncompleted_count})",
        fg="cyan",
        bold=True,
    )
    click.echo()

    # Group by location
    current_loc = None
    current_lang = None
    for loc_name, lang_name, file_name, is_completed in entries:
        if loc_name != current_loc:
            current_loc = loc_name
            current_lang = None
            click.secho(f"[{loc_name}]", fg="green", bold=True)

        if lang_name != current_lang:
            current_lang = lang_name
            click.secho(f"  {lang_name}/", fg="yellow")

        status = (
            click.style("✓", fg="green")
            if is_completed
            else click.style("○", fg="white")
        )
        click.echo(f"    {status} {file_name}.{lang_name}")


@click.command(name="find")
@click.argument("keyword", type=str)
@click.option(
    "--completed/--no-completed",
    "-c/-nc",
    default=None,
    help="Filter by completed status (default: all)",
)
def find(keyword: str, completed: bool | None):
    """
    Find problems by keyword in storage.

    KEYWORD format:
      keyword              e.g. 2447
      keyword.ext          e.g. 2447.py  (filters by language)
      .ext                 e.g. .py      (all files of a language)
      location/keyword     e.g. zeta/2447
      location/keyword.ext e.g. zeta/2447.py
      location/.ext        e.g. zeta/.py
    """
    location = None
    lang = None
    if "/" in keyword:
        parts = keyword.split("/", 1)
        location = parts[0] or None
        keyword = parts[1]

    # Extract language from extension
    if "." in keyword:
        keyword_base, ext = keyword.rsplit(".", 1)
        lang_candidate = Language.convert_ext(ext)
        if lang_candidate is not Language.UNDEFINED:
            lang = lang_candidate.value
        else:
            keyword_base = keyword
    else:
        keyword_base = keyword

    storage = pathlib.Path(STORAGE_DIR)
    if not storage.is_dir():
        raise click.ClickException(f"Storage directory '{STORAGE_DIR}' not found")

    entries = []  # (location, language, filename, is_completed)

    for loc_dir in sorted(storage.iterdir()):
        if not loc_dir.is_dir():
            continue
        loc_name = loc_dir.name

        if location and loc_name != location:
            continue

        for lang_dir in sorted(loc_dir.iterdir()):
            if not lang_dir.is_dir():
                continue
            lang_name = lang_dir.name

            if lang and lang_name != lang:
                continue

            for f in lang_dir.iterdir():
                if f.is_file() and keyword_base.lower() in f.stem.lower():
                    entries.append((loc_name, lang_name, f.stem, False))

            completed_dir = lang_dir / "completed"
            if completed_dir.is_dir():
                for f in completed_dir.iterdir():
                    if f.is_file() and keyword_base.lower() in f.stem.lower():
                        entries.append((loc_name, lang_name, f.stem, True))

    if completed is not None:
        entries = [e for e in entries if e[3] == completed]

    entries.sort(key=lambda e: (e[0], e[1], natural_sort_key(e[2])))

    if not entries:
        click.echo("No problems found.")
        return

    total = len(entries)
    completed_count = sum(1 for e in entries if e[3])
    uncompleted_count = total - completed_count

    click.secho(
        f"Found: {total} (completed: {completed_count}, uncompleted: {uncompleted_count})",
        fg="cyan",
        bold=True,
    )
    click.echo()

    current_loc = None
    current_lang = None
    for loc_name, lang_name, file_name, is_completed in entries:
        if loc_name != current_loc:
            current_loc = loc_name
            current_lang = None
            click.secho(f"[{loc_name}]", fg="green", bold=True)

        if lang_name != current_lang:
            current_lang = lang_name
            click.secho(f"  {lang_name}/", fg="yellow")

        status = (
            click.style("✓", fg="green")
            if is_completed
            else click.style("○", fg="white")
        )
        click.echo(f"    {status} {file_name}.{lang_name}")


@click.command(name="fetchprob")
@click.argument("target", type=str, nargs=1, required=True)
@click.option("--force", "-f", is_flag=True, help="Overwrite existing HTML files")
def fetchprob(target: str, force: bool):
    """
    Fetch BOJ problem HTML into storage/zeta/_static.

    TARGET:
      zeta/<id>  Fetch one problem
      zeta       Fetch all detected problem ids under storage/zeta
    """
    location, problem_id = parse_fetchprob_target(target)
    if location != "zeta":
        raise click.UsageError("only 'zeta' location is supported")

    if problem_id is not None:
        if _problem_static_html_path(problem_id).exists() and not force:
            click.echo(f"{problem_id}: skipped (already exists)")
            return

        raw_html = fetch_boj_problem_html(problem_id)
        offline_html = make_offline_problem_html(problem_id, raw_html, force=force)
        result = save_problem_html(problem_id, offline_html, force=force)
        if result == "skipped":
            click.echo(f"{problem_id}: skipped (already exists)")
        else:
            click.echo(f"{problem_id}: fetched (offline processed + images)")
        return

    ids = collect_zeta_problem_ids()
    if not ids:
        click.echo("No problem ids found in storage/zeta")
        return

    attempted = len(ids)
    fetched = 0
    skipped = 0
    failed = 0

    for pid in ids:
        try:
            if _problem_static_html_path(pid).exists() and not force:
                skipped += 1
                click.echo(f"{pid}: skipped")
                continue

            raw_html = fetch_boj_problem_html(pid)
            offline_html = make_offline_problem_html(pid, raw_html, force=force)
            result = save_problem_html(pid, offline_html, force=force)
            if result == "skipped":
                skipped += 1
                click.echo(f"{pid}: skipped")
            else:
                fetched += 1
                click.echo(f"{pid}: fetched (offline processed + images)")
        except click.ClickException as e:
            failed += 1
            click.echo(f"{pid}: failed ({e.message})")

    click.echo()
    click.secho(
        (
            f"Summary - attempted: {attempted}, fetched: {fetched}, "
            f"skipped: {skipped}, failed: {failed}"
        ),
        fg="cyan",
        bold=True,
    )



cli.add_command(run)
cli.add_command(load)
cli.add_command(init)
cli.add_command(export)
cli.add_command(state)
cli.add_command(show)
cli.add_command(find)
cli.add_command(fetchprob)

if __name__ == "__main__":
    cli()
