<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.

namespace local_kid\local;

defined('MOODLE_INTERNAL') || die();

/**
 * Single write path for `local_kid_journal` (see plans/kind-leaping-bear.md
 * §1) : `db/events.php` subscribes this class's {@see observe()} to each
 * `local_kid\event\*` class **by name, one at a time** — never to
 * `\core\event\base` generically. Registering on the base class would make
 * this observer fire on every event across the whole Moodle instance
 * (logins, course views, everything), which is exactly the risk this
 * design must avoid; adding a new action to the journal always means
 * adding one more explicit `eventname` line to `db/events.php`, never
 * widening the subscription.
 */
class journal_service {

    /** @var array domain => *_entry table name, used to resolve studentid from an entryid. */
    private const ENTRY_TABLES = [
        'eval' => 'local_kid_eval_entry',
        'stage' => 'local_kid_stage_entry',
        'capp' => 'local_kid_capp_entry',
    ];

    public static function observe(\core\event\base $event): void {
        global $DB;

        $other = $event->other ?? [];
        $domain = $other['domain'] ?? null;
        $entryid = isset($other['entryid']) ? (int) $other['entryid'] : null;

        [$studentid, $promotionid] = self::resolve_student_and_promotion($event, $domain, $entryid, $other);
        [$oldvalue, $newvalue] = self::split_diff($other['diff'] ?? null);

        $contextdata = $other;
        unset($contextdata['diff']);

        $DB->insert_record('local_kid_journal', (object) [
            'userid' => (int) $event->userid,
            'action' => self::action_name($event),
            'domain' => $domain,
            'promotionid' => $promotionid,
            'studentid' => $studentid,
            'entryid' => $entryid,
            'competencyid' => null,
            'oldvalue' => $oldvalue,
            'newvalue' => $newvalue,
            'context' => $contextdata ? json_encode($contextdata) : null,
            'timecreated' => (int) $event->timecreated,
        ]);
    }

    /**
     * A per-save `diff` (competencyid => ['old' => [...], 'new' => [...]],
     * see e.g. eval_repository::write_results()) covers several
     * competencies at once — split into the two JSON blobs stored in
     * `oldvalue`/`newvalue` (competencyid => value each), rather than one
     * journal row per competency, since a single grid submission is one
     * user action. `competencyid` on the row itself stays null in this
     * case (nullable "if applicable" — see install.xml).
     *
     * @return array{0: ?string, 1: ?string}
     */
    private static function split_diff(?array $diff): array {
        if (!$diff) {
            return [null, null];
        }
        $oldvalues = [];
        $newvalues = [];
        foreach ($diff as $competencyid => $change) {
            $oldvalues[$competencyid] = $change['old'];
            $newvalues[$competencyid] = $change['new'];
        }
        return [json_encode($oldvalues), json_encode($newvalues)];
    }

    /**
     * Best-effort "who last saved this" lookup for the conflict view
     * ({@see entry_conflict_service}) — reuses this table's existing
     * per-competency diff instead of adding a `usermodified` column to the
     * `*_result`/`*_entry` tables (none of them have one; a per-save event
     * is already logged here with enough detail to answer this precisely
     * for a competency comment, and approximately — "last save of the
     * dossier" — for a domain's single global comment, which isn't itself
     * diffed into `newvalue`).
     *
     * @return array{userid:int,timecreated:int}|null
     */
    public static function find_last_author(string $domain, int $entryid, ?int $competencyid = null): ?array {
        global $DB;

        $rows = $DB->get_records('local_kid_journal',
            ['domain' => $domain, 'entryid' => $entryid, 'action' => 'result_saved'],
            'timecreated DESC, id DESC', 'id, userid, newvalue, timecreated');

        foreach ($rows as $row) {
            if ($competencyid === null) {
                // Global comment: no per-field diff exists, fall back to the
                // most recent save of the dossier, whatever it touched.
                return ['userid' => (int) $row->userid, 'timecreated' => (int) $row->timecreated];
            }
            $decoded = $row->newvalue ? json_decode($row->newvalue, true) : null;
            if (is_array($decoded) && array_key_exists((string) $competencyid, $decoded)) {
                return ['userid' => (int) $row->userid, 'timecreated' => (int) $row->timecreated];
            }
        }
        return null;
    }

    /**
     * Short event name (e.g. `result_saved`) from the fully-qualified class
     * name — this is what fills `local_kid_journal.action`.
     */
    private static function action_name(\core\event\base $event): string {
        $class = get_class($event);
        $pos = strrpos($class, '\\');
        return $pos === false ? $class : substr($class, $pos + 1);
    }

    /**
     * @return array{0: ?int, 1: ?int} [studentid, promotionid]
     */
    private static function resolve_student_and_promotion(\core\event\base $event, ?string $domain, ?int $entryid, array $other): array {
        global $DB;

        // Caller already resolved studentid (dossier_consulted, single-fiche
        // export_generated) — trust it directly rather than re-deriving,
        // only a promotionid lookup is needed if not also given.
        if (isset($other['studentid'])) {
            $studentid = (int) $other['studentid'];
            $promotionid = isset($other['promotionid'])
                ? (int) $other['promotionid']
                : $DB->get_field('local_kid_student', 'promotionid', ['id' => $studentid], IGNORE_MISSING);
            return [$studentid, $promotionid ? (int) $promotionid : null];
        }

        // Caller already resolved promotionid (csv_imported, bulk/dashboard
        // export_generated) — no single student concerned.
        if (isset($other['promotionid'])) {
            return [null, (int) $other['promotionid']];
        }

        // Domain-scoped entry events (result_saved, workflow_changed,
        // entry_published/unpublished/reopened): studentid lives on the
        // *_entry row itself.
        if ($domain !== null && $entryid !== null && isset(self::ENTRY_TABLES[$domain])) {
            $studentid = $DB->get_field(self::ENTRY_TABLES[$domain], 'studentid', ['id' => $entryid], IGNORE_MISSING);
            if ($studentid) {
                $promotionid = $DB->get_field('local_kid_student', 'promotionid', ['id' => $studentid], IGNORE_MISSING);
                return [(int) $studentid, $promotionid ? (int) $promotionid : null];
            }
            return [null, null];
        }

        // promotion_updated / cohort_synced: objectid IS the promotionid
        // (objecttable = local_kid_promotion), no single student concerned.
        if ($event->objecttable === 'local_kid_promotion' && $event->objectid) {
            return [null, (int) $event->objectid];
        }

        // adesi_exported: only a CAPP sessionid travels in `other`, resolve
        // the promotion through the session.
        if (isset($other['sessionid'])) {
            $promotionid = $DB->get_field('local_kid_capp_session', 'promotionid', ['id' => (int) $other['sessionid']], IGNORE_MISSING);
            return [null, $promotionid ? (int) $promotionid : null];
        }

        return [null, null];
    }
}
