using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using HikvisionAttendanceService.Data;
using MySql.Data.MySqlClient;
namespace HikvisionAttendanceService;
///
/// Separate onboarding path: HRMS department employees + portal JPEG photos → new Hikvision device.
/// Does not read or write attendance_machine_face_templates (existing DB↔device flows stay unchanged).
///
internal sealed partial class HikvisionAttendanceManager
{
///
/// Provisions users/faces onto TargetMachineIps from InitialSyncDepartmentIds + employee-photo URL.
/// Gated by EnableInitialDepartmentSync only.
///
public void RunInitialDepartmentSyncCycle(CancellationToken ct)
{
if (!_config.EnableInitialDepartmentSync)
return;
ct.ThrowIfCancellationRequested();
var deptIds = (_config.InitialSyncDepartmentIds ?? new List())
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (deptIds.Count == 0)
{
_logger.OpsWarn("INITIAL_SYNC", "skipped — InitialSyncDepartmentIds is empty");
return;
}
var targets = ResolveInitialSyncTargets();
if (targets.Count == 0)
{
_logger.OpsWarn("INITIAL_SYNC", "skipped — no valid TargetMachineIps");
return;
}
var employeeIdFilter = (_config.InitialSyncEmployeeIds ?? new List())
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var employees = LoadEmployeesForInitialDepartmentSync(deptIds, employeeIdFilter, out var loadErr);
if (!string.IsNullOrWhiteSpace(loadErr))
_logger.Warn("INITIAL_SYNC: employee lookup failed err=" + loadErr);
if (employeeIdFilter.Count > 0)
_logger.Ops("INITIAL_SYNC", "Employee filter: departmentIds=" + string.Join(",", deptIds) +
" allowListCount=" + employeeIdFilter.Count +
" allowList=" + string.Join(",", employeeIdFilter) +
" matched=" + employees.Count);
_logger.Ops("INITIAL_SYNC", "departmentIds=" + string.Join(",", deptIds) +
" employeeFilter=" + (employeeIdFilter.Count > 0 ? "selective(" + employeeIdFilter.Count + ")" : "whole-department") +
" employeesFound=" + employees.Count);
if (employees.Count == 0)
return;
var maxRetries = Math.Max(1, SyncPol.HttpMaxRetries);
var photoBase = (_config.EmployeePhotoBaseUrl ?? "").Trim();
if (!string.IsNullOrWhiteSpace(photoBase) && !photoBase.EndsWith("/", StringComparison.Ordinal))
photoBase += "/";
foreach (var target in targets)
{
ct.ThrowIfCancellationRequested();
var hasSession = _sessions.Any(s =>
string.Equals((s.Device.Ip ?? "").Trim(), (target.Ip ?? "").Trim(), StringComparison.OrdinalIgnoreCase) ||
DeviceIdentity.CanonicalLookupKey(s.Device.DeviceId) == DeviceIdentity.CanonicalLookupKey(target.DeviceId));
if (!hasSession)
{
_logger.OpsWarn("INITIAL_SYNC",
"target=" + (target.DeviceId ?? "") + " ip=" + (target.Ip ?? "") +
" SKIPPED reason=\"target offline\"");
_logger.Biz(BizChannel.UserSync,
"MACHINE " + (target.DeviceId ?? ""),
"",
"Machine is not connected.",
"");
_logger.BizSeparator(BizChannel.UserSync);
continue;
}
_logger.Biz(BizChannel.UserSync,
"MACHINE " + (target.DeviceId ?? "") + " -> Total Users : " + employees.Count,
"");
var existing = FetchAllUsersIsapiForSync(target, maxRetries, ct);
var existingNos = new HashSet(existing.Select(u => u.EmployeeNo), StringComparer.OrdinalIgnoreCase);
var faceRejectedEmployees = new List<(string EmployeeNo, string Reason)>();
foreach (var emp in employees)
{
ct.ThrowIfCancellationRequested();
var employeeNo = emp.SerialNumber;
var employeeId = emp.Id;
var photoUrl = "";
var photoDownloaded = false;
var userCreated = false;
var faceUploaded = false;
var reason = "";
try
{
if (!existingNos.Contains(employeeNo))
{
var dto = new UserDto
{
EmployeeNo = employeeNo,
Name = emp.Name ?? "",
NumOfFace = 0,
NumOfFp = 0
};
if (!CreateUserOnTarget(target, dto, maxRetries, ct, out var createErr))
{
reason = string.IsNullOrWhiteSpace(createErr) ? "user_create_failed" : createErr;
continue;
}
userCreated = true;
existingNos.Add(employeeNo);
if (_config.EnableDbIntegration && _attendanceMachineUserRepository != null)
{
_attendanceMachineUserRepository.UpsertMachineUser(
target.DeviceId ?? "",
employeeNo,
emp.Name ?? "",
"hikvision-service-initial",
out _);
}
}
if (!_config.EnableEmployeePhotoSource || string.IsNullOrWhiteSpace(photoBase))
{
reason = "photo_source_disabled";
continue;
}
if (string.IsNullOrWhiteSpace(employeeId))
{
reason = "missing_employee_id";
continue;
}
photoUrl = photoBase + employeeId.Trim() + ".jpeg";
if (!TryDownloadEmployeePortalPhoto(photoUrl, out var jpegBytes, out var photoErr))
{
reason = string.IsNullOrWhiteSpace(photoErr) ? "photo_not_found" : photoErr;
continue;
}
photoDownloaded = true;
if (!IsJpegMagic(jpegBytes))
{
reason = "invalid_jpeg";
continue;
}
if (!TryPrepareInitialSyncFaceJpeg(employeeNo, jpegBytes, out var uploadBytes))
{
reason = "FACE_NOT_DETECTED";
faceRejectedEmployees.Add((employeeNo, reason));
continue;
}
if (!UploadFaceOnTarget(target, employeeNo, uploadBytes, maxRetries, ct, out var upErr))
{
reason = string.IsNullOrWhiteSpace(upErr) ? "face_upload_failed" : upErr;
faceRejectedEmployees.Add((employeeNo, reason));
continue;
}
faceUploaded = true;
}
finally
{
WriteInitialSyncEmployeeBizLog(target, employeeNo, userCreated, faceUploaded, reason);
if (photoDownloaded && faceUploaded)
{
_logger.Ops("INITIAL_SYNC",
"employeeNo=" + employeeNo +
" employeeId=" + employeeId +
" photoUrl=" + photoUrl +
" photoDownloaded=true" +
" userCreated=" + (userCreated ? "true" : "false") +
" faceUploaded=true");
}
else if (!photoDownloaded)
{
_logger.Ops("INITIAL_SYNC",
"employeeNo=" + employeeNo +
(string.IsNullOrWhiteSpace(employeeId) ? "" : (" employeeId=" + employeeId)) +
(string.IsNullOrWhiteSpace(photoUrl) ? "" : (" photoUrl=" + photoUrl)) +
" photoDownloaded=false" +
" reason=" + (string.IsNullOrWhiteSpace(reason) ? "photo_not_found" : reason));
}
else
{
_logger.Ops("INITIAL_SYNC",
"employeeNo=" + employeeNo +
" employeeId=" + employeeId +
" photoUrl=" + photoUrl +
" photoDownloaded=true" +
" userCreated=" + (userCreated ? "true" : "false") +
" faceUploaded=false" +
" reason=" + (string.IsNullOrWhiteSpace(reason) ? "face_upload_failed" : reason));
}
}
}
WriteFaceRejectedEmployeeSummary("INITIAL_SYNC", target, faceRejectedEmployees);
_logger.BizSeparator(BizChannel.UserSync);
}
}
///
/// Mirrors the DB→device sync wording in logs/user_sync_logs so provisioned users are visible
/// alongside the "removed successfully" entries, not only in internal_logs.
///
private void WriteInitialSyncEmployeeBizLog(
HikvisionAttendanceWindowsService.DeviceConfig target,
string employeeNo,
bool userCreated,
bool faceUploaded,
string reason)
{
if (userCreated)
{
_logger.Totals.UsersAdded++;
_logger.Biz(BizChannel.UserSync,
"Machine ID : " + (target.DeviceId ?? ""),
"Machine IP : " + (target.Ip ?? ""),
"",
employeeNo + " added successfully.",
"");
}
if (faceUploaded)
{
_logger.Totals.TemplatesSaved++;
_logger.Biz(BizChannel.UserSync, employeeNo + " face enrolled successfully.", "");
return;
}
_logger.Totals.TemplatesFailed++;
_logger.Biz(BizChannel.UserSync,
employeeNo + " face enrollment failed.",
"",
"Reason :",
"",
DescribeInitialSyncFailure(reason),
"");
}
private static string DescribeInitialSyncFailure(string reason)
{
if (string.IsNullOrWhiteSpace(reason))
return "Unknown error.";
switch (reason)
{
case "FACE_NOT_DETECTED":
return "No face detected in the employee photo.";
case "photo_not_found":
return "Employee photo not found on the HRMS portal.";
case "invalid_jpeg":
return "Employee photo is not a valid JPEG.";
case "photo_source_disabled":
return "Employee photo source is disabled.";
case "missing_employee_id":
return "Employee has no HRMS id, photo URL cannot be built.";
case "user_create_failed":
return "User could not be created on the device.";
default:
return reason;
}
}
///
/// Initial-sync only: crops/normalizes the HRMS photo around the detected face before FaceDataRecord.
/// Returns false only when no face is found; decode/encode problems fall back to the raw portal JPEG.
///
private bool TryPrepareInitialSyncFaceJpeg(string employeeNo, byte[] downloadedJpeg, out byte[] uploadBytes)
{
uploadBytes = downloadedJpeg;
var processed = EmployeePhotoFaceProcessor.Process(downloadedJpeg);
if (processed.Status == EmployeePhotoFaceProcessor.PhotoStatus.NoFace)
{
_logger.OpsWarn("INITIAL_SYNC",
"FACE_NOT_DETECTED employeeNo=" + employeeNo +
" original=" + processed.Original.Width + "x" + processed.Original.Height +
" uploadSkipped=true");
return false;
}
if (processed.Status != EmployeePhotoFaceProcessor.PhotoStatus.Ok)
{
_logger.OpsWarn("INITIAL_SYNC",
"PHOTO_PROCESS_FAILED employeeNo=" + employeeNo +
" err=" + processed.Error +
" fallback=original-jpeg bytes=" + downloadedJpeg.Length);
return true;
}
_logger.Ops("INITIAL_SYNC", "PHOTO_PROCESSED employeeNo=" + employeeNo + " " + processed.Describe());
_logger.Diag("INITIAL_SYNC",
"PHOTO_PROCESSED employeeNo=" + employeeNo + " " + processed.Describe() +
" jpegQuality=" + processed.JpegQuality +
" downloadedBytes=" + downloadedJpeg.Length +
" enhance[" + processed.Enhancement + "]");
uploadBytes = processed.JpegBytes;
return true;
}
private List ResolveInitialSyncTargets()
{
var targets = new List();
foreach (var targetIp in _config.TargetMachineIps ?? Enumerable.Empty())
{
var t = ResolveDeviceConfigByIp(targetIp);
if (t == null || string.IsNullOrWhiteSpace(t.Ip))
{
_logger.OpsWarn("INITIAL_SYNC", "target skipped — not found or IP empty. TargetMachineIp=\"" + targetIp + "\"");
continue;
}
if (targets.Any(x => string.Equals(x.Ip, t.Ip, StringComparison.OrdinalIgnoreCase)))
continue;
targets.Add(t);
}
foreach (var tid in _config.TargetDeviceIds ?? Enumerable.Empty())
{
var t = ResolveDeviceConfig(tid);
if (t == null || string.IsNullOrWhiteSpace(t.Ip))
continue;
if (targets.Any(x => string.Equals(x.Ip, t.Ip, StringComparison.OrdinalIgnoreCase)))
continue;
targets.Add(t);
}
return targets;
}
private List LoadEmployeesForInitialDepartmentSync(
IReadOnlyList departmentIds,
IReadOnlyList employeeIdAllowList,
out string error)
{
error = "";
var result = new List();
if (departmentIds == null || departmentIds.Count == 0)
return result;
if (!_config.EnableDbIntegration || _dbConnectionFactory == null)
{
error = "DB integration not available for initial department sync.";
return result;
}
if (!_dbConnectionFactory.TryBuildConnectionString(out var cs, out error))
return result;
var hasAllowList = employeeIdAllowList != null && employeeIdAllowList.Count > 0;
try
{
using var conn = new MySqlConnection(cs);
conn.Open();
var sql = new StringBuilder("SELECT id, serial_number FROM employee WHERE department_id IN (");
for (int i = 0; i < departmentIds.Count; i++)
{
if (i > 0) sql.Append(',');
sql.Append("@d").Append(i);
}
sql.Append(')');
if (hasAllowList)
{
sql.Append(" AND serial_number IN (");
for (int i = 0; i < employeeIdAllowList.Count; i++)
{
if (i > 0) sql.Append(',');
sql.Append("@e").Append(i);
}
sql.Append(')');
}
using var cmd = new MySqlCommand(sql.ToString(), conn);
for (int i = 0; i < departmentIds.Count; i++)
cmd.Parameters.AddWithValue("@d" + i, departmentIds[i]);
if (hasAllowList)
{
for (int i = 0; i < employeeIdAllowList.Count; i++)
cmd.Parameters.AddWithValue("@e" + i, employeeIdAllowList[i]);
}
using var rd = cmd.ExecuteReader();
var seenSerial = new HashSet(StringComparer.OrdinalIgnoreCase);
while (rd.Read())
{
var serial = rd["serial_number"]?.ToString()?.Trim() ?? "";
if (serial.Length == 0 || !seenSerial.Add(serial))
continue;
result.Add(new InitialSyncEmployee
{
Id = rd["id"]?.ToString()?.Trim() ?? "",
SerialNumber = serial,
Name = ""
});
}
}
catch (Exception ex)
{
error = ex.Message;
}
return result;
}
private static bool TryDownloadEmployeePortalPhoto(string photoUrl, out byte[] bytes, out string error)
{
bytes = Array.Empty();
error = "";
if (string.IsNullOrWhiteSpace(photoUrl))
{
error = "photo_not_found";
return false;
}
try
{
using var handler = new HttpClientHandler
{
AllowAutoRedirect = true,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(60) };
using var response = client.GetAsync(photoUrl).GetAwaiter().GetResult();
if (response.StatusCode == HttpStatusCode.NotFound)
{
error = "photo_not_found";
return false;
}
if (!response.IsSuccessStatusCode)
{
error = "photo_http_" + (int)response.StatusCode;
return false;
}
bytes = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult() ?? Array.Empty();
if (bytes.Length == 0)
{
error = "photo_not_found";
return false;
}
return true;
}
catch (Exception ex)
{
error = "photo_download_failed:" + ex.Message;
return false;
}
}
private static bool IsJpegMagic(byte[] bytes) =>
bytes != null &&
bytes.Length >= 3 &&
bytes[0] == 0xFF &&
bytes[1] == 0xD8 &&
bytes[2] == 0xFF;
private sealed class InitialSyncEmployee
{
public string Id { get; set; } = "";
public string SerialNumber { get; set; } = "";
public string Name { get; set; } = "";
}
}