"""Local single-process storage lab. Run python app.py; open 127.0.0.1:5000."""
import json
import re
import tempfile
import uuid
from datetime import datetime, timezone
from pathlib import Path

from flask import Flask, jsonify, render_template, request, send_file
from tinydb import Query, TinyDB
from tinydb.storages import JSONStorage

BASE = Path(__file__).resolve().parent
MIB = 1024 * 1024
FILE_LIMIT = 5 * MIB
TOTAL_LIMIT = 10 * MIB
ALLOWED = {".pdf", ".png", ".jpg", ".txt", ".csv"}
ORIGIN = "http://127.0.0.1:5000"


class AtomicJSONStorage(JSONStorage):
    """Replace JSON only after a complete write; not a file+DB transaction."""
    def write(self, data):
        path = Path(self._handle.name)
        temporary = None
        try:
            with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent,
                                             prefix="db-", suffix=".tmp", delete=False) as output:
                temporary = Path(output.name)
                json.dump(data, output, ensure_ascii=False, indent=2)
                output.flush()
            self._handle.close()  # Windows cannot replace an open file.
            temporary.replace(path)
        finally:
            if temporary and temporary.exists():
                temporary.unlink()
            if self._handle.closed:
                self._handle = path.open("r+", encoding="utf-8")


def validate(raw):
    if not isinstance(raw, dict) or set(raw) - {"jobs", "files"} or "jobs" not in raw:
        raise ValueError("DB 구조가 올바르지 않습니다. 자동 초기화하지 않습니다.")
    for table in raw.values():
        if not isinstance(table, dict) or any(not re.fullmatch(r"[1-9][0-9]*", key) for key in table):
            raise ValueError("DB 문서 번호가 올바르지 않습니다.")
    jobs = list(raw["jobs"].values())
    if any(not isinstance(row, dict) or set(row) != {"job_id", "title"}
           or any(not isinstance(v, str) or not v.strip() for v in row.values()) for row in jobs):
        raise ValueError("작업 정보가 올바르지 않습니다.")
    ids = {row["job_id"] for row in jobs}
    if len(ids) != len(jobs):
        raise ValueError("작업 ID가 중복되었습니다.")
    seen = set()
    for row in raw.get("files", {}).values():
        fields = {"file_id", "job_id", "original_name", "storage_key", "size", "created_at"}
        if not isinstance(row, dict) or set(row) != fields:
            raise ValueError("파일 정보가 올바르지 않습니다.")
        if any(not isinstance(row[k], str) or not row[k] for k in fields - {"size"}):
            raise ValueError("파일 문자열 정보가 올바르지 않습니다.")
        if not re.fullmatch(r"[0-9a-f]{32}", row["file_id"]) or row["file_id"] in seen:
            raise ValueError("파일 ID가 올바르지 않습니다.")
        suffix = Path(row["storage_key"]).suffix
        if suffix not in ALLOWED or row["storage_key"] != row["file_id"] + suffix:
            raise ValueError("저장 키가 올바르지 않습니다.")
        if type(row["size"]) is not int or row["size"] <= 0 or row["job_id"] not in ids:
            raise ValueError("파일 크기 또는 연결 작업이 올바르지 않습니다.")
        seen.add(row["file_id"])


def create_app(base=BASE):
    base = Path(base)
    data_path = base / "data" / "db.json"
    storage = base / "storage"
    if data_path.exists():
        validate(json.loads(data_path.read_text(encoding="utf-8")))
    else:
        jobs = json.loads((base / "sample-jobs.json").read_text(encoding="utf-8"))
        initial = {"jobs": {str(i + 1): row for i, row in enumerate(jobs)}, "files": {}}
        validate(initial)
        data_path.parent.mkdir(parents=True, exist_ok=True)
        with data_path.open("x", encoding="utf-8") as output:
            json.dump(initial, output, ensure_ascii=False, indent=2)
    storage.mkdir(exist_ok=True)
    if storage.is_symlink():
        raise ValueError("storage는 실제 실습 폴더여야 합니다.")
    app = Flask(__name__, template_folder=str(BASE / "templates"), static_folder=None)
    app.config["MAX_CONTENT_LENGTH"] = 6 * MIB  # Multipart overhead; file bytes limited separately.

    def db_open():
        validate(json.loads(data_path.read_text(encoding="utf-8")))
        return TinyDB(data_path, storage=AtomicJSONStorage, encoding="utf-8")

    def used_bytes():
        total = 0
        for path in storage.rglob("*"):
            if path.is_symlink():
                raise ValueError("storage 안의 링크 파일은 지원하지 않습니다.")
            if path.is_file():
                total += path.stat().st_size
        return total

    def file_path(key):
        path = storage / key
        if path.is_symlink() or path.resolve().parent != storage.resolve():
            raise ValueError("허용되지 않는 저장 경로입니다.")
        return path

    def error(message, status):
        return jsonify(error=message), status

    @app.before_request
    def guard():
        if request.host != "127.0.0.1:5000":
            return error("http://127.0.0.1:5000 주소를 사용하세요.", 400)
        if request.method in {"POST", "DELETE"} and request.headers.get("Origin") != ORIGIN:
            return error("같은 실습 화면에서 요청하세요.", 403)

    @app.after_request
    def headers(response):
        response.headers["Cache-Control"] = "no-store"
        response.headers["X-Content-Type-Options"] = "nosniff"
        response.headers["X-Frame-Options"] = "DENY"
        return response

    @app.errorhandler(413)
    def too_large(_):
        return error("파일 하나는 최대 5MiB입니다.", 413)

    @app.errorhandler(OSError)
    @app.errorhandler(ValueError)
    def storage_error(error_value):
        app.logger.error("Storage operation failed: %s", error_value)
        return error("파일 또는 DB 처리에 실패했습니다. 자동 초기화하지 않습니다. 저장 권한·JSON을 확인하고 다시 시도하세요.", 500)

    @app.get("/")
    def index():
        return render_template("index.html")

    @app.get("/api/jobs")
    def jobs():
        with db_open() as db:
            return jsonify(jobs=db.table("jobs").all())

    @app.get("/api/storage")
    def policy():
        used = used_bytes()
        return jsonify(used_bytes=used, remaining_bytes=max(0, TOTAL_LIMIT - used),
                       total_limit_bytes=TOTAL_LIMIT, file_limit_bytes=FILE_LIMIT,
                       allowed_extensions=sorted(ALLOWED))

    @app.get("/api/files")
    def files():
        with db_open() as db:
            rows = db.table("files").all()
            job_id = request.args.get("job_id")
            return jsonify(files=[r for r in rows if not job_id or r["job_id"] == job_id])

    @app.post("/api/files")
    def upload():
        item = request.files.get("file")
        job_id = request.form.get("job_id", "")
        if item is None or not item.filename:
            return error("첨부할 파일을 선택하세요.", 400)
        name = item.filename.replace("\\", "/").split("/")[-1].strip()
        suffix = Path(name).suffix.lower()
        if not name or any(ord(c) < 32 for c in name) or suffix not in ALLOWED:
            return error("PDF·PNG·JPG·TXT·CSV 파일만 첨부할 수 있습니다.", 400)
        with db_open() as db:
            if not db.table("jobs").contains(Query().job_id == job_id):
                return error("목록에 있는 작업을 선택하세요.", 400)
            content = item.stream.read(FILE_LIMIT + 1)
            if not content:
                return error("빈 파일은 첨부할 수 없습니다.", 400)
            if len(content) > FILE_LIMIT:
                return too_large(None)
            if used_bytes() + len(content) > TOTAL_LIMIT:
                return error("총 10MiB 한도를 넘습니다. 파일을 삭제해 공간을 확보하세요.", 409)
            file_id = uuid.uuid4().hex
            key = file_id + suffix
            target = file_path(key)
            row = dict(file_id=file_id, job_id=job_id, original_name=name, storage_key=key,
                       size=len(content), created_at=datetime.now(timezone.utc).isoformat())
            created = False
            try:
                with target.open("xb") as output:
                    created = True
                    output.write(content)
                db.table("files").insert(row)
            except (OSError, ValueError):
                try:
                    if created:
                        target.unlink(missing_ok=True)
                except OSError:
                    app.logger.error("새 파일 정리 실패: %s", key)
                raise
        return jsonify(file=row), 201

    @app.get("/api/files/<file_id>/download")
    def download(file_id):
        with db_open() as db:
            row = db.table("files").get(Query().file_id == file_id)
        if row is None:
            return error("첨부 기록이 없습니다.", 404)
        path = file_path(row["storage_key"])
        if not path.is_file():
            return error("실제 파일이 없습니다. 남은 첨부 기록은 삭제할 수 있습니다.", 404)
        return send_file(path, as_attachment=True, download_name=row["original_name"],
                         mimetype="application/octet-stream")

    @app.delete("/api/files/<file_id>")
    def delete(file_id):
        with db_open() as db:
            table = db.table("files")
            row = table.get(Query().file_id == file_id)
            if row is None:
                return error("첨부 기록이 없습니다.", 404)
            file_path(row["storage_key"]).unlink(missing_ok=True)
            # If this fails, the record remains so the user can retry cleanup.
            table.remove(Query().file_id == file_id)
        return jsonify(message="파일과 첨부 기록을 삭제했습니다.")

    return app


if __name__ == "__main__":
    try:
        create_app().run(host="127.0.0.1", port=5000, debug=False, threaded=False, load_dotenv=False)
    except (OSError, ValueError) as error:
        raise SystemExit(f"시작 실패: {error}. 기존 DB는 자동 초기화하지 않습니다.")
