001package com.studentgui.apphelpers;
002
003import java.sql.Connection;
004import java.sql.DriverManager;
005import java.sql.PreparedStatement;
006import java.sql.ResultSet;
007import java.sql.SQLException;
008import java.sql.Statement;
009import java.time.LocalDate;
010import java.util.ArrayList;
011import java.util.HashMap;
012import java.util.List;
013import java.util.Map;
014
015/**
016 * Centralized database helper for the normalized SQLite schema.
017 *
018 * <p>Provides convenience methods to get-or-create Students and ProgressTypes,
019 * create ProgressSessions, ensure AssessmentParts, insert/fetch assessment
020 * results, and save session-specific notes. Use these helpers instead of
021 * running per-page DDL throughout the codebase.</p>
022 */
023public class Database {
024
025    /**
026     * Private constructor to prevent instantiation of this utility class.
027     */
028    private Database() {
029        throw new AssertionError("Database is a utility class");
030    }
031
032    /**
033     * Obtain a new JDBC Connection to the application SQLite database.
034     * Caller is responsible for closing the connection (try-with-resources is recommended).
035     *
036     * @return new Connection
037     * @throws SQLException if the driver cannot open the database
038     */
039    private static Connection getConnection() throws SQLException {
040        String url = "jdbc:sqlite:" + Helpers.DATABASE_PATH.toString();
041        return DriverManager.getConnection(url);
042    }
043    
044    /**
045     * Get a student id by name, creating a new Student row when none exists.
046     *
047     * @param name student display name
048     * @return id of the existing or newly created student
049     * @throws SQLException on database errors
050     */
051    public static int getOrCreateStudent(final String name) throws SQLException {
052        try (Connection c = getConnection()) {
053            try (PreparedStatement ps = c.prepareStatement("SELECT id FROM Student WHERE name = ?")) {
054                ps.setString(1, name);
055                try (ResultSet rs = ps.executeQuery()) {
056                    if (rs.next()) {
057                        return rs.getInt(1);
058                    }
059                }
060            }
061            try (PreparedStatement ps = c.prepareStatement("INSERT INTO Student(name) VALUES (?)", Statement.RETURN_GENERATED_KEYS)) {
062                ps.setString(1, name);
063                ps.executeUpdate();
064                try (ResultSet keys = ps.getGeneratedKeys()) {
065                    if (keys.next()) {
066                        return keys.getInt(1);
067                    }
068                }
069            }
070        }
071        throw new SQLException("Failed to create or retrieve student");
072    }
073
074    /**
075     * Get or create a ProgressType row by name.
076     *
077     * @param name progress type display name
078     * @return database id of the progress type
079     * @throws SQLException on database errors
080     */
081    public static int getOrCreateProgressType(final String name) throws SQLException {
082        try (Connection c = getConnection()) {
083            try (PreparedStatement ps = c.prepareStatement("SELECT id FROM ProgressType WHERE name = ?")) {
084                ps.setString(1, name);
085                try (ResultSet rs = ps.executeQuery()) {
086                    if (rs.next()) {
087                        return rs.getInt(1);
088                    }
089                }
090            }
091            try (PreparedStatement ps = c.prepareStatement("INSERT INTO ProgressType(name) VALUES (?)", Statement.RETURN_GENERATED_KEYS)) {
092                ps.setString(1, name);
093                ps.executeUpdate();
094                try (ResultSet keys = ps.getGeneratedKeys()) {
095                    if (keys.next()) {
096                        return keys.getInt(1);
097                    }
098                }
099            }
100        }
101        throw new SQLException("Failed to create or retrieve ProgressType");
102    }
103
104    /**
105     * Ensure AssessmentPart rows exist for the given progress type. This uses
106     * SQL "INSERT OR IGNORE" so existing parts are preserved.
107     *
108     * @param progressTypeId id of the ProgressType
109     * @param codes array of part codes to ensure
110     * @throws SQLException on database errors
111     */
112    public static void ensureAssessmentParts(final int progressTypeId, final String[] codes) throws SQLException {
113        try (Connection c = getConnection()) {
114            try (PreparedStatement ps = c.prepareStatement("INSERT OR IGNORE INTO AssessmentPart(progress_type_id, code, description) VALUES (?, ?, NULL)")) {
115                for (String code : codes) {
116                    ps.setInt(1, progressTypeId);
117                    ps.setString(2, code);
118                    ps.addBatch();
119                }
120                ps.executeBatch();
121            }
122        }
123    }
124
125    /**
126     * Remove any AssessmentPart rows for the given progress type whose code is
127     * not present in the provided canonical codes array. This helps clean up
128     * legacy/malformed entries that could cause part ordering mismatches.
129     *
130     * @param progressTypeId id of the ProgressType
131     * @param allowedCodes canonical set of codes to keep
132     * @throws SQLException on database errors
133     */
134    public static void cleanupAssessmentParts(final int progressTypeId, final String[] allowedCodes) throws SQLException {
135        if (allowedCodes == null || allowedCodes.length == 0) {
136            return;
137        }
138        try (Connection c = getConnection()) {
139            StringBuilder sb = new StringBuilder();
140            for (int i = 0; i < allowedCodes.length; i++) {
141                if (i > 0) { sb.append(','); }
142                sb.append('?');
143            }
144            String sql = "DELETE FROM AssessmentPart WHERE progress_type_id = ? AND code NOT IN (" + sb.toString() + ")";
145            try (PreparedStatement ps = c.prepareStatement(sql)) {
146                ps.setInt(1, progressTypeId);
147                for (int i = 0; i < allowedCodes.length; i++) {
148                    ps.setString(i + 2, allowedCodes[i]);
149                }
150                ps.executeUpdate();
151            }
152        }
153    }
154
155    /**
156     * Create a ProgressSession for a student and progress type on the given date.
157     *
158     * @param studentId existing student id
159     * @param progressTypeId existing progress type id
160     * @param date session date
161     * @return generated ProgressSession id
162     * @throws SQLException on database errors
163     */
164    public static int createProgressSession(final int studentId, final int progressTypeId, final LocalDate date) throws SQLException {
165        try (Connection c = getConnection()) {
166            try (PreparedStatement ps = c.prepareStatement("INSERT INTO ProgressSession(student_id, progress_type_id, date, notes) VALUES (?, ?, ?, NULL)", Statement.RETURN_GENERATED_KEYS)) {
167                ps.setInt(1, studentId);
168                ps.setInt(2, progressTypeId);
169                ps.setString(3, date.toString());
170                ps.executeUpdate();
171                try (ResultSet keys = ps.getGeneratedKeys()) {
172                    if (keys.next()) { return keys.getInt(1); }
173                }
174            }
175        }
176        throw new SQLException("Failed to create ProgressSession");
177    }
178
179    /**
180     * Insert assessment results for a session. The {@code codes} and {@code scores}
181     * arrays must be parallel and correspond to existing AssessmentPart codes.
182     * Unknown part codes are ignored.
183     *
184     * @param sessionId progress session id
185     * @param progressTypeId progress type id
186     * @param codes array of part codes
187     * @param scores array of integer scores
188     * @throws SQLException on database errors
189     */
190    public static void insertAssessmentResults(final int sessionId, final int progressTypeId, final String[] codes, final int[] scores) throws SQLException {
191        if (codes.length != scores.length) { throw new IllegalArgumentException("codes and scores length mismatch"); }
192        try (Connection c = getConnection()) {
193            // cache part ids
194            Map<String, Integer> partIdMap = new HashMap<>();
195            try (PreparedStatement ps = c.prepareStatement("SELECT id, code FROM AssessmentPart WHERE progress_type_id = ?")) {
196                ps.setInt(1, progressTypeId);
197                try (ResultSet rs = ps.executeQuery()) {
198                    while (rs.next()) {
199                        partIdMap.put(rs.getString("code"), rs.getInt("id"));
200                    }
201                }
202            }
203            try (PreparedStatement ins = c.prepareStatement("INSERT INTO AssessmentResult(session_id, part_id, score) VALUES (?, ?, ?)") ) {
204                for (int i = 0; i < codes.length; i++) {
205                    Integer partId = partIdMap.get(codes[i]);
206                    if (partId == null) {
207                        // skip unknown part
208                        continue;
209                    }
210                    ins.setInt(1, sessionId);
211                    ins.setInt(2, partId);
212                    ins.setInt(3, scores[i]);
213                    ins.addBatch();
214                }
215                ins.executeBatch();
216            }
217        }
218    }
219
220    /**
221     * Fetch the latest assessment result rows for a named student and progress type.
222     * Each returned row is a list of integer scores for the parts in canonical
223     * part order.
224     *
225     * @param studentName student display name
226     * @param progressTypeName progress type display name
227     * @param limit maximum number of recent sessions to fetch
228     * @return list of rows, each row is a list of integer scores
229     * @throws SQLException on database errors
230     */
231    public static List<List<Integer>> fetchLatestAssessmentResults(final String studentName, final String progressTypeName, final int limit) throws SQLException {
232        List<List<Integer>> result = new ArrayList<>();
233        try (Connection c = getConnection()) {
234            Integer studentId = null;
235            try (PreparedStatement ps = c.prepareStatement("SELECT id FROM Student WHERE name = ?")) {
236                ps.setString(1, studentName);
237                try (ResultSet rs = ps.executeQuery()) {
238                    if (rs.next()) { studentId = rs.getInt(1); }
239                }
240            }
241            if (studentId == null) { return result; }
242
243            Integer progressTypeId = null;
244            try (PreparedStatement ps = c.prepareStatement("SELECT id FROM ProgressType WHERE name = ?")) {
245                ps.setString(1, progressTypeName);
246                try (ResultSet rs = ps.executeQuery()) {
247                    if (rs.next()) { progressTypeId = rs.getInt(1); }
248                }
249            }
250            if (progressTypeId == null) { return result; }
251
252            // get parts in canonical order (by id)
253            List<Integer> partIds = new ArrayList<>();
254            try (PreparedStatement ps = c.prepareStatement("SELECT id, code FROM AssessmentPart WHERE progress_type_id = ? ORDER BY id ASC")) {
255                ps.setInt(1, progressTypeId);
256                try (ResultSet rs = ps.executeQuery()) {
257                    while (rs.next()) { partIds.add(rs.getInt("id")); }
258                }
259            }
260
261            // get latest session ids for this student and progress type
262            List<Integer> sessionIds = new ArrayList<>();
263            try (PreparedStatement ps = c.prepareStatement("SELECT id FROM ProgressSession WHERE student_id = ? AND progress_type_id = ? ORDER BY id DESC LIMIT ?")) {
264                ps.setInt(1, studentId);
265                ps.setInt(2, progressTypeId);
266                ps.setInt(3, limit);
267                try (ResultSet rs = ps.executeQuery()) {
268                    while (rs.next()) { sessionIds.add(rs.getInt(1)); }
269                }
270            }
271
272            // For each session, fetch scores mapped to parts
273            for (Integer sid : sessionIds) {
274                Map<Integer, Integer> scoreByPart = new HashMap<>();
275                try (PreparedStatement ps = c.prepareStatement("SELECT part_id, score FROM AssessmentResult WHERE session_id = ?")) {
276                    ps.setInt(1, sid);
277                    try (ResultSet rs = ps.executeQuery()) {
278                        while (rs.next()) {
279                            scoreByPart.put(rs.getInt("part_id"), rs.getInt("score"));
280                        }
281                    }
282                }
283                List<Integer> row = new ArrayList<>();
284                for (Integer pid : partIds) {
285                    Integer s = scoreByPart.get(pid);
286                    row.add(s == null ? 0 : s);
287                }
288                result.add(row);
289            }
290        }
291        return result;
292    }
293
294    /**
295     * Simple, immutable holder for time-series assessment results.
296     *
297     * <p>Contains a chronologically ordered list of session {@code dates}
298     * and a parallel list of integer score rows. Each entry in {@code rows}
299     * corresponds to the parts for a progress type in canonical order.
300     */
301    public static class ResultsWithDates {
302        /**
303         * Ordered session dates (oldest first). Can be empty when no sessions exist.
304         */
305        public final java.util.List<java.time.LocalDate> dates;
306
307        /**
308         * Parallel rows of integer scores. Each inner list corresponds to the
309         * assessment parts for a single session in canonical part order. May be
310         * empty when there are no sessions.
311         */
312        public final java.util.List<java.util.List<Integer>> rows;
313
314        /**
315         * Create a ResultsWithDates instance.
316         *
317         * @param dates ordered session dates (oldest-first)
318         * @param rows parallel list of score rows matching {@code dates}
319         */
320        public ResultsWithDates(java.util.List<java.time.LocalDate> dates, java.util.List<java.util.List<Integer>> rows) {
321            this.dates = dates;
322            this.rows = rows;
323        }
324    }
325
326    /**
327     * Fetch the latest assessment rows along with their session dates.
328     * Rows and dates are ordered oldest-first to facilitate time series plotting.
329     *
330     * @param studentName student display name to filter results for
331     * @param progressTypeName progress type display name (e.g., "Braille")
332     * @param limit maximum number of recent sessions to return
333     * @return ResultsWithDates holding an ordered list of session dates and parallel rows of scores
334     * @throws SQLException on database errors
335     */
336    public static ResultsWithDates fetchLatestAssessmentResultsWithDates(final String studentName, final String progressTypeName, final int limit) throws SQLException {
337        java.util.List<java.util.List<Integer>> result = new ArrayList<>();
338        java.util.List<java.time.LocalDate> dates = new ArrayList<>();
339        try (Connection c = getConnection()) {
340            Integer studentId = null;
341            try (PreparedStatement ps = c.prepareStatement("SELECT id FROM Student WHERE name = ?")) {
342                ps.setString(1, studentName);
343                try (ResultSet rs = ps.executeQuery()) {
344                    if (rs.next()) {
345                        studentId = rs.getInt(1);
346                    }
347                }
348            }
349            if (studentId == null) { return new ResultsWithDates(dates, result); }
350
351            Integer progressTypeId = null;
352            try (PreparedStatement ps = c.prepareStatement("SELECT id FROM ProgressType WHERE name = ?")) {
353                ps.setString(1, progressTypeName);
354                try (ResultSet rs = ps.executeQuery()) {
355                    if (rs.next()) {
356                        progressTypeId = rs.getInt(1);
357                    }
358                }
359            }
360            if (progressTypeId == null) {
361                return new ResultsWithDates(dates, result);
362            }
363
364            // get parts in canonical order (by id)
365            java.util.List<Integer> partIds = new ArrayList<>();
366            try (PreparedStatement ps = c.prepareStatement("SELECT id, code FROM AssessmentPart WHERE progress_type_id = ? ORDER BY id ASC")) {
367                ps.setInt(1, progressTypeId);
368                try (ResultSet rs = ps.executeQuery()) {
369                    while (rs.next()) {
370                        partIds.add(rs.getInt("id"));
371                    }
372                }
373            }
374
375            // get latest session ids and dates for this student and progress type
376            java.util.List<java.lang.Integer> sessionIds = new ArrayList<>();
377            java.util.List<java.time.LocalDate> sessionDates = new ArrayList<>();
378            try (PreparedStatement ps = c.prepareStatement("SELECT id, date FROM ProgressSession WHERE student_id = ? AND progress_type_id = ? ORDER BY id DESC LIMIT ?")) {
379                ps.setInt(1, studentId); ps.setInt(2, progressTypeId); ps.setInt(3, limit);
380                try (ResultSet rs = ps.executeQuery()) {
381                    while (rs.next()) {
382                        sessionIds.add(rs.getInt("id"));
383                        sessionDates.add(java.time.LocalDate.parse(rs.getString("date")));
384                    }
385                }
386            }
387
388            // We want chronological order (oldest first)
389            java.util.Collections.reverse(sessionIds);
390            java.util.Collections.reverse(sessionDates);
391
392            // For each session, fetch scores mapped to parts and append row
393            for (Integer sid : sessionIds) {
394                Map<Integer, Integer> scoreByPart = new HashMap<>();
395                try (PreparedStatement ps = c.prepareStatement("SELECT part_id, score FROM AssessmentResult WHERE session_id = ?")) {
396                    ps.setInt(1, sid);
397                    try (ResultSet rs = ps.executeQuery()) {
398                        while (rs.next()) {
399                            scoreByPart.put(rs.getInt("part_id"), rs.getInt("score"));
400                        }
401                    }
402                }
403                java.util.List<Integer> row = new ArrayList<>();
404                for (Integer pid : partIds) {
405                    Integer s = scoreByPart.get(pid);
406                    row.add(s == null ? 0 : s);
407                }
408                result.add(row);
409            }
410            dates.addAll(sessionDates);
411        }
412        return new ResultsWithDates(dates, result);
413    }
414
415    /**
416     * Insert a keyboarding-specific result linked to a ProgressSession.
417     *
418     * @param sessionId existing session id
419     * @param program program or curriculum name
420     * @param topic topic or lesson name
421     * @param speed words-per-minute
422     * @param accuracy accuracy percent
423     * @throws SQLException on database errors
424     */
425    public static void insertKeyboardingResult(final int sessionId, final String program, final String topic, final int speed, final int accuracy) throws SQLException {
426        try (Connection c = getConnection()) {
427            try (PreparedStatement ps = c.prepareStatement("INSERT INTO KeyboardingResult(session_id, program, topic, speed, accuracy) VALUES (?, ?, ?, ?, ?)")) {
428                ps.setInt(1, sessionId);
429                ps.setString(2, program);
430                ps.setString(3, topic);
431                ps.setInt(4, speed);
432                ps.setInt(5, accuracy);
433                ps.executeUpdate();
434            }
435        }
436    }
437
438    /**
439     * Save free-form notes for a given ProgressSession.
440     *
441     * @param sessionId progress session id
442     * @param notes free-form notes text
443     * @throws SQLException on database errors
444     */
445    public static void saveSessionNotes(final int sessionId, final String notes) throws SQLException {
446        try (Connection c = getConnection()) {
447            try (PreparedStatement ps = c.prepareStatement("UPDATE ProgressSession SET notes = ? WHERE id = ?")) {
448                ps.setString(1, notes);
449                ps.setInt(2, sessionId);
450                ps.executeUpdate();
451            }
452        }
453    }
454
455    /**
456     * Save structured contact log details for a given ProgressSession. This
457     * will insert or replace a single ContactLog row tied to the session.
458     *
459     * @param sessionId existing session id
460     * @param studentName student display name
461     * @param date session date as text
462     * @param guardianName guardian or parent name
463     * @param contactMethod method of contact (phone/email/etc)
464     * @param phoneNumber phone number string
465     * @param emailAddress email address string
466     * @param contactResponse short description of response
467     * @param contactGeneral general contact summary
468     * @param contactSpecific specific items discussed
469     * @param contactNotes free-form notes
470     * @throws SQLException on database errors
471     */
472    public static void saveContactLog(final int sessionId, final String studentName, final String date, final String guardianName, final String contactMethod, final String phoneNumber, final String emailAddress, final String contactResponse, final String contactGeneral, final String contactSpecific, final String contactNotes) throws SQLException {
473        try (Connection c = getConnection()) {
474            try (PreparedStatement ps = c.prepareStatement("INSERT OR REPLACE INTO ContactLog(session_id, student_name, date, guardian_name, contact_method, phone_number, email_address, contact_response, contact_general, contact_specific, contact_notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") ) {
475                ps.setInt(1, sessionId);
476                ps.setString(2, studentName);
477                ps.setString(3, date);
478                ps.setString(4, guardianName);
479                ps.setString(5, contactMethod);
480                ps.setString(6, phoneNumber);
481                ps.setString(7, emailAddress);
482                ps.setString(8, contactResponse);
483                ps.setString(9, contactGeneral);
484                ps.setString(10, contactSpecific);
485                ps.setString(11, contactNotes);
486                ps.executeUpdate();
487            }
488        }
489    }
490
491    /**
492     * Fetch the most recent ContactLog entry for the given student name.
493     * Returns a map of column names to string values, or null if none found.
494    *
495    * @param studentName student display name to search for
496    * @return map of contact log columns to values or null when not found
497    * @throws SQLException on database errors
498     */
499    public static com.studentgui.apphelpers.dto.ContactPayload fetchLatestContactLog(final String studentName) throws SQLException {
500        try (Connection c = getConnection()) {
501            Integer studentId = null;
502            try (PreparedStatement ps = c.prepareStatement("SELECT id FROM Student WHERE name = ?")) {
503                ps.setString(1, studentName);
504                try (ResultSet rs = ps.executeQuery()) {
505                    if (rs.next()) {
506                        studentId = rs.getInt(1);
507                    }
508                }
509            }
510            if (studentId == null) {
511                return null;
512            }
513
514            // Find the latest session id for ProgressType 'ContactLog'
515            Integer ptId = null;
516            try (PreparedStatement ps = c.prepareStatement("SELECT id FROM ProgressType WHERE name = ?")) {
517                ps.setString(1, "ContactLog");
518                try (ResultSet rs = ps.executeQuery()) {
519                    if (rs.next()) {
520                        ptId = rs.getInt(1);
521                    }
522                }
523            }
524            if (ptId == null) {
525                return null;
526            }
527
528            Integer sessionId = null;
529            try (PreparedStatement ps = c.prepareStatement("SELECT id FROM ProgressSession WHERE student_id = ? AND progress_type_id = ? ORDER BY id DESC LIMIT 1")) {
530                ps.setInt(1, studentId);
531                ps.setInt(2, ptId);
532                try (ResultSet rs = ps.executeQuery()) {
533                    if (rs.next()) {
534                        sessionId = rs.getInt(1);
535                    }
536                }
537            }
538            if (sessionId == null) {
539                return null;
540            }
541
542            try (PreparedStatement ps = c.prepareStatement("SELECT student_name, date, guardian_name, contact_method, phone_number, email_address, contact_response, contact_general, contact_specific, contact_notes FROM ContactLog WHERE session_id = ? ORDER BY id DESC LIMIT 1")) {
543                ps.setInt(1, sessionId);
544                try (ResultSet rs = ps.executeQuery()) {
545                    if (rs.next()) {
546                        com.studentgui.apphelpers.dto.ContactPayload p = new com.studentgui.apphelpers.dto.ContactPayload(
547                            sessionId,
548                            rs.getString("guardian_name"),
549                            rs.getString("contact_method"),
550                            rs.getString("phone_number"),
551                            rs.getString("email_address"),
552                            rs.getString("contact_response"),
553                            rs.getString("contact_general"),
554                            rs.getString("contact_specific"),
555                            rs.getString("contact_notes")
556                        );
557                        return p;
558                    }
559                }
560            }
561            return null;
562        }
563    }
564
565}