import json
import os
from pathlib import Path

from flask import (
    Flask,
    abort,
    current_app,
    g,
    jsonify,
    make_response,
    redirect,
    render_template,
    request,
    session,
    url_for,
)

from testready.accounts import (
    ensure_student,
    get_account,
    get_submitted_attempt,
    is_admin,
    is_disabled,
    normalize_code,
    record_login,
    seed_admin_from_env,
    submitted_attempts,
    upsert_account,
    valid_code,
)
from testready.db import connect, init_db
from testready.engine import Engine, EngineError, load_bank
from testready.graph import load_tree
from testready.review import apply_action, item_paths, patch_item_file, summary
from testready.scoring import DISCLAIMER, HEADLINE_WITHHELD

ROOT = Path(__file__).resolve().parent


def create_app(test_config=None):
    app = Flask(
        __name__,
        instance_path=str(ROOT / "instance"),
        static_folder="static",
        template_folder="templates",
    )
    app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-not-for-production")
    app.config["DATABASE"] = str(Path(app.instance_path) / "app.db")

    Path(app.instance_path).mkdir(parents=True, exist_ok=True)
    with open(ROOT / "config.json", encoding="utf-8") as f:
        app.config["TESTREADY"] = json.load(f)
    with open(ROOT / "data" / "misconceptions.json", encoding="utf-8") as f:
        app.config["MISCONCEPTIONS"] = json.load(f)

    if test_config:
        app.config.update(test_config)

    conn = connect(app.config["DATABASE"])
    init_db(conn)
    seed_admin_from_env(conn)
    conn.close()

    register_routes(app)
    return app


def get_engine() -> Engine:
    if "engine" not in g:
        conn = connect(current_app.config["DATABASE"])
        graph = load_tree(ROOT / "data" / "math-tree.json")
        bank = current_app.config.get("ITEM_BANK") or load_bank(ROOT / "data" / "items")
        g.db = conn
        g.engine = Engine(conn, graph, bank, current_app.config["TESTREADY"])
    return g.engine


def _json_error(exc: EngineError):
    return jsonify(ok=False, reason=str(exc)), exc.status


def _learner_id():
    code = session.get("account_code")
    if code:
        return code
    cookie = request.cookies.get("learner_id")
    if cookie and valid_code(cookie):
        return cookie
    if current_app.testing:
        return "test@local"
    abort(401)


def _login_required() -> bool:
    return not current_app.testing


_OPEN_ENDPOINTS = frozenset(
    {
        "login",
        "login_post",
        "logout",
        "inactive",
        "healthz",
        "static",
        "test_demo",
        "formula_sheet",
        "calculator_reference",
        "renderer_gallery",
        "calc_gallery",
        "review_questions",
        "review_queue",
        "review_get_item",
        "review_post_item",
    }
)


def _safe_next(raw) -> str:
    nxt = str(raw or "")
    if nxt.startswith("/") and not nxt.startswith("//"):
        return nxt
    return url_for("home")


def _require_admin():
    if _login_required() and not is_admin(_account()):
        abort(404)
    if not _login_required() and not _review_on():
        abort(404)


def _accounts_conn():
    get_engine()
    return g.db


def _account() -> dict | None:
    code = session.get("account_code")
    if not code:
        return None
    return get_account(_accounts_conn(), code)


def _with_learner(resp, learner_id: str):
    resp.set_cookie("learner_id", learner_id, samesite="Lax", max_age=60 * 60 * 24 * 400)
    return resp


def _body():
    if request.is_json:
        return request.get_json(silent=True) or {}
    return request.form


def _review_on() -> bool:
    return bool(
        current_app.debug
        or current_app.testing
        or os.environ.get("TESTREADY_DEV") == "1"
        or os.environ.get("TESTREADY_REVIEW") == "1"
        or os.environ.get("FLASK_RUN_FROM_CLI") == "true"
    )


def _review_items_dir() -> Path:
    return Path(current_app.config.get("REVIEW_ITEMS_DIR") or ROOT / "data" / "items")


def _review_bank() -> dict:
    cached = current_app.config.get("ITEM_BANK")
    if cached is not None:
        return cached
    return load_bank(_review_items_dir())


def register_routes(app):
    @app.teardown_appcontext
    def close_db(_exc=None):
        conn = g.pop("db", None)
        if conn is not None:
            conn.close()
        g.pop("engine", None)

    @app.before_request
    def gate_account():
        if request.endpoint in _OPEN_ENDPOINTS:
            return None
        if request.path.startswith("/static/"):
            return None
        if request.endpoint == "report" and (request.view_args or {}).get("attempt_id") == "demo":
            return None
        if not _login_required():
            return None
        account = _account()
        if account is None:
            if request.path.startswith("/api/"):
                return jsonify(ok=False, reason="login required"), 401
            return redirect(url_for("login", next=request.path))
        if is_disabled(account) and request.endpoint not in ("inactive", "logout"):
            return redirect(url_for("inactive"))
        return None

    @app.get("/login")
    def login():
        account = _account()
        if account and not is_disabled(account):
            return redirect(url_for("home"))
        return render_template("login.html", error=None, next_path=request.args.get("next") or "")

    @app.post("/login")
    def login_post():
        body = _body()
        code = normalize_code(body.get("code"))
        next_path = body.get("next") or request.args.get("next") or ""
        error = None
        if not valid_code(code):
            error = "Enter the code you were given."
        else:
            account = ensure_student(_accounts_conn(), code)
            session["account_code"] = code
            record_login(_accounts_conn(), code)
            if is_disabled(account):
                return redirect(url_for("inactive"))
            return redirect(_safe_next(next_path))
        return render_template("login.html", error=error, next_path=next_path), 400

    @app.post("/logout")
    def logout():
        session.clear()
        resp = redirect(url_for("login"))
        resp.delete_cookie("learner_id")
        return resp

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

    @app.get("/")
    def home():
        account = _account() if _login_required() else None
        admin = is_admin(account)
        show_admin = admin or (not _login_required() and _review_on())
        return render_template(
            "home.html",
            profiles=app.config["TESTREADY"]["profiles"],
            default_profile=app.config["TESTREADY"].get("default_profile", "first_eval"),
            review_enabled=_review_on() and (admin or not _login_required()),
            show_playtest=False,
            is_admin=admin,
            show_admin=show_admin,
            show_dev_links=admin or not _login_required(),
            account_code=(account or {}).get("code"),
        )

    @app.get("/test/demo")
    def test_demo():
        return render_template("test_demo.html")

    @app.get("/test/<attempt_id>")
    def test_player(attempt_id):
        try:
            get_engine().snapshot(attempt_id)
        except EngineError as exc:
            abort(exc.status)
        return render_template("test.html", attempt_id=attempt_id)

    @app.get("/formula-sheet")
    def formula_sheet():
        return render_template("formula_sheet.html")

    @app.get("/calculator-reference")
    def calculator_reference():
        return render_template("calculator_reference.html")

    @app.post("/api/session")
    def create_session():
        body = _body()
        profile = body.get("profile") or "first_eval"
        learner_id = _learner_id()
        try:
            snap = get_engine().start(learner_id, profile)
        except EngineError as exc:
            return _json_error(exc)
        if body.get("profile") and not request.is_json:
            resp = redirect(url_for("test_player", attempt_id=snap["attempt_id"]))
            return _with_learner(resp, learner_id)
        resp = make_response(jsonify(snap))
        return _with_learner(resp, learner_id)

    @app.get("/api/session/<attempt_id>")
    def get_session(attempt_id):
        seq = request.args.get("seq", type=int)
        try:
            return jsonify(get_engine().snapshot(attempt_id, view_seq=seq))
        except EngineError as exc:
            return _json_error(exc)

    @app.post("/api/session/<attempt_id>/answer")
    def post_answer(attempt_id):
        body = _body()
        try:
            return jsonify(
                get_engine().answer(attempt_id, int(body["seq"]), str(body["key"]))
            )
        except (KeyError, TypeError, ValueError):
            return jsonify(ok=False, reason="seq and key required"), 400
        except EngineError as exc:
            return _json_error(exc)

    @app.post("/api/session/<attempt_id>/flag")
    def post_flag(attempt_id):
        body = _body()
        try:
            flagged = body.get("flagged")
            if isinstance(flagged, str):
                flagged = flagged.lower() in ("1", "true", "yes")
            return jsonify(
                get_engine().flag(attempt_id, int(body["seq"]), bool(flagged))
            )
        except (KeyError, TypeError, ValueError):
            return jsonify(ok=False, reason="seq and flagged required"), 400
        except EngineError as exc:
            return _json_error(exc)

    @app.post("/api/session/<attempt_id>/notes")
    def post_notes(attempt_id):
        body = _body()
        try:
            return jsonify(get_engine().notes(attempt_id, str(body.get("notes") or "")))
        except EngineError as exc:
            return _json_error(exc)

    @app.post("/api/session/<attempt_id>/submit")
    def post_submit(attempt_id):
        try:
            return jsonify(get_engine().submit(attempt_id))
        except EngineError as exc:
            return _json_error(exc)

    @app.post("/test/<attempt_id>/submit")
    def test_submit(attempt_id):
        try:
            get_engine().submit(attempt_id)
        except EngineError as exc:
            abort(exc.status)
        return redirect(url_for("report", attempt_id=attempt_id))

    @app.get("/report/<attempt_id>")
    def report(attempt_id):
        ctx = {
            "disclaimer": DISCLAIMER,
            "headline_withheld": HEADLINE_WITHHELD,
            "demo": attempt_id == "demo",
            "in_progress": False,
            "band": None,
            "plan": None,
            "heatmap": [],
        }
        if attempt_id == "demo":
            return render_template("report.html", **ctx)
        try:
            data = get_engine().report_data(
                attempt_id, current_app.config["MISCONCEPTIONS"]
            )
        except EngineError as exc:
            abort(exc.status)
        if data["status"] == "in_progress":
            ctx["in_progress"] = True
            return render_template("report.html", **ctx)
        ctx["band"] = data["band"]
        ctx["plan"] = data["plan"]
        ctx["heatmap"] = data["heatmap"]
        return render_template("report.html", **ctx)

    @app.get("/admin/scores")
    def admin_scores():
        _require_admin()
        rows = submitted_attempts(_accounts_conn())
        return render_template("admin_scores.html", rows=rows)

    @app.get("/admin/scores/<login>/<when>")
    def admin_score_detail(login, when):
        _require_admin()
        att = get_submitted_attempt(_accounts_conn(), login, when)
        if att is None:
            abort(404)
        data = get_engine().report_data(att["id"], current_app.config["MISCONCEPTIONS"])
        return render_template(
            "admin_score.html",
            login=att["learner_id"],
            submitted_at=att["submitted_at"],
            profile_label=att["profile_label"],
            disclaimer=DISCLAIMER,
            headline_withheld=HEADLINE_WITHHELD,
            band=data["band"],
            plan=data["plan"],
            heatmap=data["heatmap"],
        )

    @app.post("/admin/accounts")
    def admin_add_account():
        _require_admin()
        code = normalize_code(_body().get("code"))
        make_admin = str(_body().get("admin") or "").lower() in ("1", "on", "true", "yes")
        if not valid_code(code):
            abort(400)
        upsert_account(_accounts_conn(), code, admin=make_admin)
        return redirect(url_for("home"))

    @app.get("/healthz")
    def healthz():
        return "ok", 200, {"Content-Type": "text/plain; charset=utf-8"}

    @app.get("/dev/renderers")
    def renderer_gallery():
        if not (
            current_app.debug or os.environ.get("TESTREADY_DEV") == "1"
        ):
            abort(404)
        return render_template("dev_renderers.html", gallery=GALLERY)

    @app.get("/dev/calc")
    def calc_gallery():
        if not (
            current_app.debug or os.environ.get("TESTREADY_DEV") == "1"
        ):
            abort(404)
        return render_template("dev_calc.html")

    @app.get("/review")
    def review_questions():
        if not _review_on():
            abort(404)
        return render_template("review.html")

    @app.get("/api/review/queue")
    def review_queue():
        if not _review_on():
            abort(404)
        return jsonify(summary(_review_bank()))

    @app.get("/api/review/item/<item_id>")
    def review_get_item(item_id):
        if not _review_on():
            abort(404)
        item = _review_bank().get(item_id)
        if not item:
            abort(404)
        return jsonify(item)

    @app.post("/api/review/item/<item_id>")
    def review_post_item(item_id):
        if not _review_on():
            abort(404)
        bank = _review_bank()
        if item_id not in bank:
            abort(404)
        body = _body()
        try:
            fields = apply_action(body.get("notes") or "", body.get("action") or "")
        except ValueError:
            return jsonify(ok=False, reason="action must be help or approve"), 400
        path = item_paths(_review_items_dir()).get(item_id)
        if path is None:
            abort(404)
        patch_item_file(path, fields)
        bank[item_id].update(fields)
        payload = summary(bank)
        payload["id"] = item_id
        payload.update(fields)
        return jsonify(payload)


GALLERY = {
    "stacked": {
        "type": "stacked_arithmetic",
        "spec": {"operator": "×", "rows": ["2.35", "4.1"]},
    },
    "number_line": {
        "type": "number_line",
        "spec": {
            "min": -3,
            "max": 5,
            "tick": 1,
            "minor": 0.5,
            "points": [{"x": 1.5, "label": "P", "style": "open"}],
        },
    },
    "number_line_union": {
        "type": "number_line",
        "spec": {
            "min": -6,
            "max": 6,
            "tick": 1,
            "intervals": [
                {"min": None, "max": -2, "max_style": "closed"},
                {"min": 2, "max": None, "min_style": "closed"},
            ],
        },
    },
    "xy_graph": {
        "type": "xy_graph",
        "spec": {
            "xmin": -1,
            "xmax": 7,
            "ymin": -1,
            "ymax": 8,
            "title": "Hours and cups sold",
            "xlabel": "hours",
            "ylabel": "cups of coffee",
            "points": [
                {"x": 1, "y": 2, "style": "closed"},
                {"x": 2, "y": 3, "style": "closed"},
                {"x": 3, "y": 5, "style": "closed"},
                {"x": 4, "y": 4, "style": "closed"},
                {"x": 5, "y": 7, "style": "closed"},
            ],
        },
    },
    "table": {
        "type": "table",
        "spec": {
            "caption": "Saturday plant sale",
            "header": ["Plant", "Price", "Sold"],
            "align": ["text", "numeric", "numeric"],
            "rows": [
                ["Herb pot", "$4", "11"],
                ["Tomato", "$3", "8"],
                ["Sunflower", "$2", "17"],
            ],
        },
    },
    "polygon": {
        "type": "rectilinear_polygon",
        "spec": {
            "unit": "ft",
            "vertices": [[0, 0], [16, 0], [16, 7], [9, 7], [9, 12], [0, 12]],
            "labels": {
                "e1": "7 ft",
                "e2": "7 ft",
                "e4": "9 ft",
                "e5": "12 ft",
            },
            "unlabeled": ["e0", "e3"],
            "show_right_angle_marks": True,
            "fill": "#cfe6d4",
        },
    },
    "choice_table": {
        "type": "table",
        "spec": {
            "caption": "Shift log",
            "header": ["Week", "Hours"],
            "align": ["numeric", "numeric"],
            "rows": [["1", "12"], ["2", "15"], ["3", "9"]],
        },
    },
    "choice_graph": {
        "type": "xy_graph",
        "spec": {
            "xmin": 0,
            "xmax": 5,
            "ymin": 0,
            "ymax": 5,
            "points": [
                {"x": 1, "y": 1, "style": "closed"},
                {"x": 2, "y": 3, "style": "closed"},
                {"x": 3, "y": 2, "style": "closed"},
            ],
            "lines": [{"x1": 0, "y1": 0, "x2": 4, "y2": 4}],
        },
    },
    "circle": {
        "type": "circle",
        "spec": {"diameter": 5, "unit": "cm", "dimension": "diameter"},
    },
    "right_triangle": {
        "type": "right_triangle",
        "spec": {
            "a": 6,
            "b": 8,
            "unit": "ft",
            "labels": {"a": "6 ft", "b": "8 ft"},
            "unlabeled": ["c"],
            "show_right_angle": True,
        },
    },
    "solid_sketch": {
        "type": "solid_sketch",
        "spec": {
            "kind": "cone",
            "point": "down",
            "unit": "in",
            "labels": {"diameter": "4 in", "height": "9 in"},
        },
    },
    "solid_prism": {
        "type": "solid_sketch",
        "spec": {
            "kind": "rectangular_prism",
            "unit": "ft",
            "labels": {"length": "8 ft", "width": "3 ft", "height": "5 ft"},
        },
    },
    "solid_pyramid": {
        "type": "solid_sketch",
        "spec": {
            "kind": "rectangular_pyramid",
            "unit": "m",
            "labels": {"length": "8 m", "width": "8 m", "height": "6 m"},
        },
    },
    "solid_tri_prism": {
        "type": "solid_sketch",
        "spec": {
            "kind": "right_triangular_prism",
            "unit": "cm",
            "labels": {"a": "6 cm", "b": "8 cm", "length": "10 cm"},
        },
    },
    "bar_chart": {
        "type": "bar_chart",
        "spec": {
            "title": "Boats launched",
            "ylabel": "Boats",
            "categories": ["Wed", "Thu", "Fri"],
            "values": [12, 19, 15],
        },
    },
    "dot_plot": {
        "type": "dot_plot",
        "spec": {"min": 0, "max": 8, "tick": 1, "stacks": {"2": 1, "3": 3, "5": 2}},
    },
    "histogram": {
        "type": "histogram",
        "spec": {
            "title": "Wait times",
            "ylabel": "Visits",
            "bins": [
                {"label": "0–5", "count": 2},
                {"label": "6–10", "count": 5},
                {"label": "11–15", "count": 1},
            ],
        },
    },
}


app = create_app()
