<?php
/**
 * PmServices — Slice 8S-3 · PM Execution
 *
 * Lifecycle:
 *   DRAFT (spawned from schedule) → IN_PROGRESS (FS starts) → DONE (FS finishes)
 *   → REVIEWED (PC/PM signs off) → CLIENT_ACCEPTED (client signature captured)
 *
 * The template snapshot is captured at spawn time (template_id + template_version) so
 * that even if the template is later cloned or re-versioned, this in-flight PM stays
 * on the version it was rendered against.
 */
class PmServices extends Model
{
    public function nextPmNo(): string
    {
        $db  = new DB();
        $yr  = date('Y');
        $r   = $db->mySelect("SELECT pm_no FROM pm_services WHERE pm_no LIKE :p ORDER BY c0d DESC LIMIT 1", [':p' => 'PM-' . $yr . '-%']);
        $next = 1;
        if (!empty($r) && preg_match('/PM-\d{4}-(\d+)/', (string)$r[0]['pm_no'], $m)) $next = ((int)$m[1]) + 1;
        return sprintf('PM-%s-%05d', $yr, $next);
    }

    public function list(string $status = '', ?int $contractId = null): array
    {
        $db = new DB();
        $where = "WHERE 1=1";
        $params = [];
        if ($status !== '')             { $where .= " AND ps.status = :s"; $params[':s'] = $status; }
        if ($contractId !== null)       { $where .= " AND ps.contract_id = :c"; $params[':c'] = $contractId; }
        return $db->mySelect(
            "SELECT ps.c0d, ps.pm_no, ps.scheduled_for, ps.started_at, ps.finished_at, ps.status,
                    ps.done_by_name, ps.client_decision,
                    a.asset_no, a.serial_no, a.make, a.model,
                    s.name AS site_name, t.code AS asset_type_code, t.icon AS asset_icon,
                    tpl.code AS template_code, tpl.name AS template_name,
                    c.contract_no, c.customer_name
               FROM pm_services ps
               JOIN service_contract_assets a ON a.c0d = ps.contract_asset_id
          LEFT JOIN service_contract_sites s  ON s.c0d = a.site_id
          LEFT JOIN asset_types t             ON t.c0d = a.asset_type_id
          LEFT JOIN pm_templates tpl          ON tpl.c0d = ps.template_id
          LEFT JOIN service_contracts c       ON c.c0d = ps.contract_id
               $where
              ORDER BY COALESCE(ps.scheduled_for, ps.created_on) DESC, ps.c0d DESC",
            $params
        ) ?: [];
    }

    public function find(int $id): array
    {
        $db = new DB();
        $r = $db->mySelect(
            "SELECT ps.*, a.asset_no, a.serial_no, a.make, a.model, a.size_value, a.size_unit,
                    s.name AS site_name, s.site_code, s.physical_address,
                    t.code AS asset_type_code, t.name AS asset_type_name, t.icon AS asset_icon,
                    tpl.code AS template_code, tpl.name AS template_name,
                    c.contract_no, c.customer_name
               FROM pm_services ps
               JOIN service_contract_assets a ON a.c0d = ps.contract_asset_id
          LEFT JOIN service_contract_sites s  ON s.c0d = a.site_id
          LEFT JOIN asset_types t             ON t.c0d = a.asset_type_id
          LEFT JOIN pm_templates tpl          ON tpl.c0d = ps.template_id
          LEFT JOIN service_contracts c       ON c.c0d = ps.contract_id
              WHERE ps.c0d = :id",
            [':id' => $id]
        );
        return $r[0] ?? [];
    }

    public function findingsFor(int $pmId): array
    {
        $db = new DB();
        return $db->mySelect(
            "SELECT f.*, i.section, i.task, i.input_type, i.unit, i.is_required, i.tracks_qty, i.sort_order
               FROM pm_template_items i
          LEFT JOIN pm_service_findings f ON f.template_item_id = i.c0d AND f.pm_service_id = :p
              WHERE i.template_id = (SELECT template_id FROM pm_services WHERE c0d = :p2)
              ORDER BY i.sort_order, i.c0d",
            [':p' => $pmId, ':p2' => $pmId]
        ) ?: [];
    }

    public function partsUsedFor(int $pmId): array
    {
        $db = new DB();
        return $db->mySelect(
            "SELECT pu.*, p.sku, p.name AS part_name, p.uom
               FROM pm_service_parts_used pu
          LEFT JOIN spare_parts p ON p.c0d = pu.part_id
              WHERE pu.pm_service_id = :p
              ORDER BY pu.c0d",
            [':p' => $pmId]
        ) ?: [];
    }

    /** Spawn a PM from a schedule row (or ad-hoc). Returns pm_service id. */
    public function spawn(int $contractAssetId, int $templateId, ?int $scheduleId, ?string $scheduledFor): int
    {
        $db = new DB();
        $a = $db->mySelect("SELECT contract_id, site_id FROM service_contract_assets WHERE c0d = :id", [':id' => $contractAssetId]);
        if (empty($a)) throw new RuntimeException('Asset not found.');
        $t = $db->mySelect("SELECT version FROM pm_templates WHERE c0d = :id", [':id' => $templateId]);
        if (empty($t)) throw new RuntimeException('Template not found.');
        $no = $this->nextPmNo();
        $fb = $db->insertUpdate(
            "INSERT INTO pm_services
                (pm_no, contract_id, contract_asset_id, template_id, template_version, site_id, schedule_id, scheduled_for, status)
             VALUES (:no, :c, :a, :t, :v, :s, :sch, :sf, 'DRAFT')",
            [':no'=>$no, ':c'=>(int)$a[0]['contract_id'], ':a'=>$contractAssetId, ':t'=>$templateId,
             ':v'=>(int)$t[0]['version'], ':s'=>$a[0]['site_id'] ? (int)$a[0]['site_id'] : null,
             ':sch'=>$scheduleId, ':sf'=>$scheduledFor]
        );
        return (int)($fb['last_id'] ?? 0);
    }

    public function start(int $pmId, array $d, int $uid, string $uname): void
    {
        $pm = $this->find($pmId);
        if (empty($pm)) throw new RuntimeException('PM not found.');
        if (!in_array($pm['status'], ['DRAFT','IN_PROGRESS'], true)) throw new RuntimeException('Cannot start — status ' . $pm['status']);
        $db = new DB();
        $db->insertUpdate(
            "UPDATE pm_services SET status='IN_PROGRESS', started_at=COALESCE(started_at, NOW()),
                gps_lat=:lat, gps_lng=:lng, ppe_confirmed=:ppe, done_by=:u, done_by_name=:un, edited_on=NOW()
              WHERE c0d=:id",
            [':lat'=>isset($d['gps_lat']) && $d['gps_lat'] !== '' ? (float)$d['gps_lat'] : null,
             ':lng'=>isset($d['gps_lng']) && $d['gps_lng'] !== '' ? (float)$d['gps_lng'] : null,
             ':ppe'=>!empty($d['ppe_confirmed']) ? 1 : 0, ':u'=>$uid, ':un'=>$uname, ':id'=>$pmId]
        );
    }

    public function saveFinding(int $pmId, array $d): int
    {
        $itemId = (int)($d['template_item_id'] ?? 0);
        if ($itemId <= 0) throw new InvalidArgumentException('template_item_id required.');
        $db = new DB();
        // Upsert — one finding per (pm, item)
        $existing = $db->mySelect("SELECT c0d FROM pm_service_findings WHERE pm_service_id = :p AND template_item_id = :i", [':p'=>$pmId, ':i'=>$itemId]);
        $data = [
            ':p'   => $pmId, ':i' => $itemId,
            ':rc'  => isset($d['result_check']) && $d['result_check'] !== '' ? (int)$d['result_check'] : null,
            ':rn'  => isset($d['result_number']) && $d['result_number'] !== '' ? (float)$d['result_number'] : null,
            ':rt'  => trim((string)($d['result_text'] ?? '')) ?: null,
            ':rp'  => trim((string)($d['result_photo_path'] ?? '')) ?: null,
            ':rs'  => trim((string)($d['result_signature_path'] ?? '')) ?: null,
            ':qr'  => isset($d['qty_replaced']) && $d['qty_replaced'] !== '' ? (float)$d['qty_replaced'] : null,
            ':rm'  => trim((string)($d['remark'] ?? '')) ?: null,
        ];
        if (!empty($existing)) {
            $data[':id'] = (int)$existing[0]['c0d'];
            $db->insertUpdate(
                "UPDATE pm_service_findings SET
                    result_check=:rc, result_number=:rn, result_text=:rt, result_photo_path=:rp,
                    result_signature_path=:rs, qty_replaced=:qr, remark=:rm
                  WHERE c0d=:id",
                $data
            );
            return (int)$existing[0]['c0d'];
        }
        $fb = $db->insertUpdate(
            "INSERT INTO pm_service_findings
                (pm_service_id, template_item_id, result_check, result_number, result_text, result_photo_path,
                 result_signature_path, qty_replaced, remark)
             VALUES (:p, :i, :rc, :rn, :rt, :rp, :rs, :qr, :rm)",
            $data
        );
        return (int)($fb['last_id'] ?? 0);
    }

    public function addPart(int $pmId, array $d): int
    {
        $partId = (int)($d['part_id'] ?? 0);
        $qty    = (float)($d['qty'] ?? 0);
        if ($partId <= 0 || $qty <= 0) throw new InvalidArgumentException('part_id + qty required.');
        $src = strtoupper((string)($d['source'] ?? 'STOCK'));
        if (!in_array($src, ['STOCK','PROCURED','CLIENT_SUPPLIED','ADHOC'], true)) $src = 'STOCK';
        $db = new DB();
        // Snapshot unit_cost
        $p = $db->mySelect("SELECT unit_cost FROM spare_parts WHERE c0d = :id", [':id' => $partId]);
        $cost = !empty($p) && $p[0]['unit_cost'] !== null ? (float)$p[0]['unit_cost'] : null;
        $fb = $db->insertUpdate(
            "INSERT INTO pm_service_parts_used (pm_service_id, part_id, qty, unit_cost_at_use, source, notes)
             VALUES (:p, :pt, :q, :uc, :s, :n)",
            [':p'=>$pmId, ':pt'=>$partId, ':q'=>$qty, ':uc'=>$cost, ':s'=>$src, ':n'=>trim((string)($d['notes'] ?? '')) ?: null]
        );
        return (int)($fb['last_id'] ?? 0);
    }

    public function finish(int $pmId, string $notes, int $uid, string $uname): void
    {
        $pm = $this->find($pmId);
        if (empty($pm)) throw new RuntimeException('PM not found.');
        if ($pm['status'] !== 'IN_PROGRESS') throw new RuntimeException('Only IN_PROGRESS PMs can be finished (status ' . $pm['status'] . ').');
        // Verify all required items have findings
        $missing = $this->countMissingRequiredFindings($pmId);
        if ($missing > 0) throw new RuntimeException($missing . ' required finding(s) missing.');
        $db = new DB();
        $db->insertUpdate(
            "UPDATE pm_services SET status='DONE', finished_at=NOW(), notes=:nt,
                done_by=COALESCE(done_by, :u), done_by_name=COALESCE(done_by_name, :un), edited_on=NOW()
              WHERE c0d=:id",
            [':nt'=>trim($notes) ?: null, ':u'=>$uid, ':un'=>$uname, ':id'=>$pmId]
        );
        // Update contract_pm_schedule.last_done_on + roll next_due_on forward
        if (!empty($pm['schedule_id'])) {
            $sch = $db->mySelect("SELECT interval_months FROM service_contract_pm_schedule WHERE c0d = :id", [':id' => (int)$pm['schedule_id']]);
            $nextDue = null;
            if (!empty($sch) && (int)$sch[0]['interval_months'] > 0) {
                $nextDue = date('Y-m-d', strtotime('+' . (int)$sch[0]['interval_months'] . ' months'));
            }
            $db->insertUpdate(
                "UPDATE service_contract_pm_schedule SET last_done_on = CURDATE(), next_due_on = COALESCE(:nd, next_due_on) WHERE c0d = :id",
                [':nd' => $nextDue, ':id' => (int)$pm['schedule_id']]
            );
        }
    }

    public function review(int $pmId, string $notes, int $uid, string $uname): void
    {
        $db = new DB();
        $pm = $this->find($pmId);
        if (empty($pm)) throw new RuntimeException('PM not found.');
        if ($pm['status'] !== 'DONE') throw new RuntimeException('Only DONE PMs can be reviewed (status ' . $pm['status'] . ').');
        $db->insertUpdate(
            "UPDATE pm_services SET status='REVIEWED', reviewed_by=:u, reviewed_by_name=:un, reviewed_on=NOW(),
                notes=CONCAT(COALESCE(notes,''), :sep, :nt), edited_on=NOW()
              WHERE c0d=:id",
            [':u'=>$uid, ':un'=>$uname, ':sep'=>trim($notes) ? "\n---\nReviewer: " : '',
             ':nt'=>trim($notes), ':id'=>$pmId]
        );
    }

    public function clientAccept(int $pmId, string $clientName, string $signaturePath, string $decision): void
    {
        $decision = strtoupper($decision);
        if (!in_array($decision, ['ACCEPTED','REJECTED'], true)) throw new InvalidArgumentException('decision must be ACCEPTED or REJECTED.');
        $db = new DB();
        $db->insertUpdate(
            "UPDATE pm_services SET status = CASE WHEN :dec='ACCEPTED' THEN 'CLIENT_ACCEPTED' ELSE status END,
                client_decision = :dec, client_decided_by_name = :cn, client_signature_path = :sp, edited_on = NOW()
              WHERE c0d = :id",
            [':dec'=>$decision, ':cn'=>trim($clientName) ?: null, ':sp'=>trim($signaturePath) ?: null, ':id'=>$pmId]
        );
    }

    private function countMissingRequiredFindings(int $pmId): int
    {
        $db = new DB();
        $r  = $db->mySelect(
            "SELECT COUNT(*) n
               FROM pm_template_items i
          LEFT JOIN pm_service_findings f ON f.template_item_id = i.c0d AND f.pm_service_id = :p
              WHERE i.template_id = (SELECT template_id FROM pm_services WHERE c0d = :p2)
                AND i.is_required = 1
                AND f.c0d IS NULL",
            [':p' => $pmId, ':p2' => $pmId]
        );
        return (int)($r[0]['n'] ?? 0);
    }

    /* ── Slice 8S-7 · Photo evidence ─────────────────────────────────── */

    private const MAX_PHOTO_BYTES = 10 * 1024 * 1024;
    private const OK_PHOTO_MIMES = ['image/jpeg','image/png','image/webp','image/gif','application/pdf'];

    public function photosFor(int $pmId, ?string $kind = null): array
    {
        $db = new DB();
        $where = "WHERE pm_service_id = :p";
        $params = [':p' => $pmId];
        if ($kind !== null) { $where .= " AND kind = :k"; $params[':k'] = strtoupper($kind); }
        return $db->mySelect(
            "SELECT * FROM pm_service_photos $where ORDER BY c0d",
            $params
        ) ?: [];
    }

    public function findPhoto(int $id): array
    {
        $db = new DB();
        $r  = $db->mySelect("SELECT * FROM pm_service_photos WHERE c0d = :id", [':id' => $id]);
        return $r[0] ?? [];
    }

    public function addPhoto(int $pmId, array $file, string $kind, string $caption, int $uid, string $uname): int
    {
        $pm = $this->find($pmId);
        if (empty($pm)) throw new RuntimeException('PM not found.');
        [$rel, $orig, $mime, $size] = $this->stashPhoto($file, $pmId, 'pm-photos');
        $kindU = strtoupper($kind);
        if (!in_array($kindU, ['SITE','PPE','FINDING','RESOLUTION','CLIENT_SIGNATURE','OTHER'], true)) $kindU = 'OTHER';
        $db = new DB();
        $fb = $db->insertUpdate(
            "INSERT INTO pm_service_photos
                (pm_service_id, file_path, original_name, mime, size_bytes, kind, caption, uploaded_by, uploaded_by_name)
             VALUES (:p, :fp, :on, :mm, :sz, :k, :cap, :u, :un)",
            [':p'=>$pmId, ':fp'=>$rel, ':on'=>$orig, ':mm'=>$mime, ':sz'=>$size,
             ':k'=>$kindU, ':cap'=>trim($caption) ?: null, ':u'=>$uid, ':un'=>$uname]
        );
        return (int)($fb['last_id'] ?? 0);
    }

    public function removePhoto(int $photoId): void
    {
        $p = $this->findPhoto($photoId);
        if (empty($p)) return;
        $abs = ROOT . '/' . ltrim((string)$p['file_path'], '/');
        if (is_file($abs)) @unlink($abs);
        $db = new DB();
        $db->insertUpdate("DELETE FROM pm_service_photos WHERE c0d = :id", [':id' => $photoId]);
    }

    /**
     * Slice 8S-7 · Save a photo directly into a finding (result_photo_path or result_signature_path).
     * Called when the template item's input_type is PHOTO or SIGNATURE.
     */
    public function saveFindingPhoto(int $pmId, int $templateItemId, array $file, bool $isSignature = false): string
    {
        $folder = $isSignature ? 'pm-signatures' : 'pm-findings';
        [$rel, , , ] = $this->stashPhoto($file, $pmId, $folder);
        $db = new DB();
        // Ensure finding row exists (upsert-lite)
        $exist = $db->mySelect("SELECT c0d FROM pm_service_findings WHERE pm_service_id = :p AND template_item_id = :i", [':p'=>$pmId, ':i'=>$templateItemId]);
        if (!empty($exist)) {
            $col = $isSignature ? 'result_signature_path' : 'result_photo_path';
            $db->insertUpdate(
                "UPDATE pm_service_findings SET $col = :fp WHERE c0d = :id",
                [':fp'=>$rel, ':id'=>(int)$exist[0]['c0d']]
            );
        } else {
            $col = $isSignature ? 'result_signature_path' : 'result_photo_path';
            $db->insertUpdate(
                "INSERT INTO pm_service_findings (pm_service_id, template_item_id, $col) VALUES (:p, :i, :fp)",
                [':p'=>$pmId, ':i'=>$templateItemId, ':fp'=>$rel]
            );
        }
        return $rel;
    }

    private function stashPhoto(array $file, int $ownerId, string $folder): array
    {
        $err = (int)($file['error'] ?? 0);
        if ($err !== UPLOAD_ERR_OK)                        throw new RuntimeException('Upload error ' . $err);
        $size = (int)($file['size'] ?? 0);
        if ($size <= 0 || $size > self::MAX_PHOTO_BYTES)   throw new RuntimeException('Photo size invalid.');
        $mime = @mime_content_type($file['tmp_name']) ?: (string)($file['type'] ?? '');
        if (!in_array($mime, self::OK_PHOTO_MIMES, true))  throw new RuntimeException('Unsupported photo type: ' . $mime);
        $orig = (string)($file['name'] ?? 'photo');
        $safe = preg_replace('/[^A-Za-z0-9._\-]+/', '_', $orig);
        $ext  = pathinfo($safe, PATHINFO_EXTENSION);
        $base = pathinfo($safe, PATHINFO_FILENAME) . '_' . substr(bin2hex(random_bytes(4)), 0, 8) . ($ext ? '.' . $ext : '');
        $dir  = ROOT . '/attachments/' . $folder . '/' . $ownerId;
        if (!is_dir($dir) && !@mkdir($dir, 0777, true) && !is_dir($dir)) throw new RuntimeException('Could not create attachment folder.');
        $abs = $dir . '/' . $base;
        if (!@move_uploaded_file($file['tmp_name'], $abs))              throw new RuntimeException('Failed to save photo.');
        return ['attachments/' . $folder . '/' . $ownerId . '/' . $base, $orig, $mime, $size];
    }
}
