﻿<?xml version="1.0" encoding="utf-8"?>
<AlvaoApplication xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" ModelVersion="1">
  <Applications>
    <Application id="2">
      <UniqueId>9286e51e-ace2-409c-95c3-961bb44629a2</UniqueId>
      <Version>4</Version>
      <AdvancedSettings>
      <Setting>
        <Name>CiscoMeraki.Token</Name>
        <Value/>
        </Setting>
        <Setting>
        <Name>CiscoMeraki.WebhookSharedSecret</Name>
        <Value/>
        </Setting>
      </AdvancedSettings>

      <Name>Cisco Meraki Connector</Name>
      <Description />
      <Scripts>
        <Script id="2">
          <Name>Logger</Name>
          <Code>using NLog;
using Alvao.API.Internal;

namespace Alvao.CustomApps.CiscoMerakiConnector;

public static class Logger
{
    public static ILogger Log = TenantDiagnosticsLog.Get();
}
</Code>
          <IsLibCode>true</IsLibCode>
        </Script>
        <Script id="3">
          <Name>CiscoMerakiPeriodicAction</Name>
          <Code>using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using System.Threading;
using System.Threading.Tasks;
using Alvao.API.Common.Model.Database;
using Alvao.API.Utils;
using Alvao.Apps.API;
using Alvao.Context;
using Dapper;
using Microsoft.Data.SqlClient;
using static Alvao.API.AM.Model.Kind;
using Alvao.API.AM.Model;
using static Alvao.API.Common.Model.Database.tblClass;
using Alvao.API.Common;
using static Alvao.Global.ModuleInfo;

namespace Alvao.CustomApps.CiscoMerakiConnector;

public class CiscoMerakiPeriodicAction : IPeriodicAction
{
    public const string ConnectorName = "Cisco Meraki";
    public string Name
    {
        get =&gt; "PeriodicAction";
        set { }
    }

    private readonly CiscoMerakiConnectorRepository CiscoMerakiConnectorRepository = new CiscoMerakiConnectorRepository();
    private readonly CiscoMerakiClientService CiscoMerakiClientService = new CiscoMerakiClientService();
    private int ImportedObjectsNodeFolderNodeId = 0;
    private Dictionary&lt;int, bool&gt; CheckedTemplatePropMap = new();

    public void OnPeriod(SqlConnection con)
    {
        if(!Activation.IsModuleActivated(ModuleId.MonitoringConnectors))
        {
            Logger.Log.Warn($"{ConnectorName} Connector cannot be used. Module MonitoringConnectors is not activated.");
            return;
        }

        if (string.IsNullOrEmpty(DbProperty.CiscoMerakiToken))
        {
            Logger.Log.Warn($"{ConnectorName} Connector is not properly set. Check CiscoMeraki.Token in advanced settings.");
            return;
        }

        try
        {
            Task task = Task.Run(async () =&gt; await CreateNewOrUpdateChangedDevices(CancellationToken.None));
            task.Wait();
        }
        catch (Exception ex)
        {
            Logger.Log.Error(ex, $"{ConnectorName} Connector: Error importing devices.");
        }
    }

    private async Task CreateNewOrUpdateChangedDevices(CancellationToken cancellationToken)
    {
        var systemPersonId = Alvao.API.Common.Person.GetSystem().iPersonId;
        DateTime? lastAmIntuneCheckInDateTime = CiscoMerakiConnectorRepository.GetCiscoMerakiLastCheckInDevicesDateTime();

        IEnumerable&lt;CiscoMerakiDevice&gt; newOrUpdatedDevices = await GetNewOrUpdatedDevices(lastAmIntuneCheckInDateTime, cancellationToken);
        cancellationToken.ThrowIfCancellationRequested();

        if (newOrUpdatedDevices is null || !newOrUpdatedDevices.Any())
            return;

        CiscoMerakiConnectorRepository.CreateConnectorScannerIfNotExists();
        CiscoMerakiConnectorRepository.ClearScannerPropertyLockoutTable();

        var collisionChecker = new ImportCollisionChecker();
        foreach (var device in newOrUpdatedDevices.OrderBy(d =&gt; d.CiscoMerakiLastCheckIn))
        {
            cancellationToken.ThrowIfCancellationRequested();

            using var scope = AlvaoContext.GetConnectionScope();
            try
            {
                scope.BeginTransaction();
                tblNode node;
                if (device.NodeId is null || device.NodeId == 0)
                {
                    node = CreateNewDevice(device);
                    if (node.intNodeId == 0)
                        continue;

                    Logger.Log.Info("{0} Connector: Created '{1}': {2}", ConnectorName, device.DeviceType.ToString(), device.Hostname);
                }
                else
                {
                    CheckAndAddPropertiesToTemplateByHostnameModel(device.Hostname, device.Model, device.DeviceType);
                    node = new tblNode() { intNodeId = device.NodeId.Value };
                }
                if (node is null)
                    throw new NullReferenceException($"Error: Device ID: '{device.CiscoMerakiDeviceId}' not found in DB and creation failed.");

                CiscoMerakiConnectorRepository.RecalculateScannerPropertyLockoutTable(device, node.lintClassId);

                int updatedCnt = UpdateDeviceProperties(node.intNodeId, device);
                scope.CommitTransaction();
            }
            catch (NullReferenceException ex)
            {
                Logger.Log.Error(ex, $"{ConnectorName} Connector: Error processing device.");
            }
        }
        Logger.Log.Info("{0} Connector: Processed {1} devices", ConnectorName, newOrUpdatedDevices?.Count() ?? 0);
    }

    public tblNode GetExistingDeviceNode(CiscoMerakiDevice device)
    {
        if (device.CiscoMerakiDeviceId is null)
            throw new ArgumentNullException("Missing CiscoMeraki ID.");

        tblNode node = CiscoMerakiConnectorRepository.GetDeviceNodeByAnotherProperty((int)KindCode.CiscoMerakiDeviceId, device.CiscoMerakiDeviceId.ToString(), false);

        if (node == null)
        {
            var isSnBlacklisted = CiscoMerakiConnectorRepository.GetBlacklistedValuesForKindcode((int)KindCode.SerialNo).Any(blacklisted =&gt; device.SerialNo == blacklisted);
            if (!isSnBlacklisted)
            {
                node = CiscoMerakiConnectorRepository.GetDeviceNodeByAnotherProperty((int)KindCode.SerialNo, device.SerialNo, true);
            }

            if (node is null)
            {
                node = CiscoMerakiConnectorRepository.GetDeviceNodeByAnotherProperty((int)KindCode.MACAddress, device.MacAddresses, true, string.IsNullOrEmpty(device.SerialNo));
            }
        }
        return node;
    }

    private tblNode CreateNewDevice(CiscoMerakiDevice managedDevice)
    {
        if (ImportedObjectsNodeFolderNodeId == 0)
            ImportedObjectsNodeFolderNodeId = CiscoMerakiConnectorRepository.GetOrCreateImportedObjectsFolder();
        int classId = CheckAndAddPropertiesToTemplateByHostnameModel(managedDevice.Hostname, managedDevice.Model, managedDevice.DeviceType);

        tblNode node = new();
        if (IsComputer(classId))
            node = CiscoMerakiConnectorRepository.CreateSimpleComputer(classId, ImportedObjectsNodeFolderNodeId, managedDevice.Hostname, Alvao.API.Common.Person.GetSystem().iPersonId);
        else
            node = CiscoMerakiConnectorRepository.CreateObjectByClass(classId, ImportedObjectsNodeFolderNodeId, Alvao.API.Common.Person.GetSystem().iPersonId);

        return node;
    }

    private bool IsComputer(int classId)
    {
        using (var scope = AlvaoContext.GetConnectionScope())
            return scope.Connection.ExecuteScalar&lt;bool?&gt;("select bComputer from tblClass where intClassId=@classId", new { classId }, scope.Transaction) ?? false;
    }

    private int UpdateDeviceProperties(int nodeId, CiscoMerakiDevice device)
    {
        int updated = 0;
        using (var scope = AlvaoContext.GetConnectionScope())
        {
            scope.BeginTransaction();
            foreach (var mapping in device.GetPropertyMapping())
            {
                // If configuration has not changed, only update availability (last check-in)
                if (!device.ConfigurationChanged &amp;&amp; (tblKind.KindCode)mapping.Key != tblKind.KindCode.CiscoMerakiLastCheckIn)
                    continue;

                object value = typeof(CiscoMerakiDevice).GetProperty(mapping.Value)?.GetValue(device);

                if (value is null)
                    continue;

                string strValue = value is DateTime? ? ((DateTime)value).ToString("yyyy-MM-ddTHH:mm:ssZ") : value.ToString();

                bool shouldLog = (tblKind.KindCode)mapping.Key != tblKind.KindCode.CiscoMerakiLastCheckIn;
                Alvao.API.AM.ObjectProperty.Update(nodeId, (tblKind.KindCode)mapping.Key, strValue, false, shouldLog);
                updated++;
            }
            scope.CommitTransaction();
        }

        return updated;
    }

    private async Task&lt;IEnumerable&lt;CiscoMerakiDevice&gt;&gt; GetNewOrUpdatedDevices(DateTime? lastSync, CancellationToken cancellationToken)
    {
        var devices = await CiscoMerakiClientService.GetNewOrUpdatedDevices(lastSync, cancellationToken);

        var collisionChecker = new ImportCollisionChecker();
        foreach (var device in devices)
        {
            tblNode node = GetExistingDeviceNode(device);
            if (node is null)
            {
                collisionChecker.AddImportedInfo(0, device);
            }
            else
            {
                device.NodeId = node.intNodeId;
                collisionChecker.AddImportedInfo(node.intNodeId, device, node.ImportDuplicateAlert);
            }
        }
        
        SetAndLogCollisions(collisionChecker);
        var toRemove = collisionChecker.GetCollidingRecords().SelectMany(c =&gt; c.Value.CollidingObjects);
        devices.ToList().RemoveAll(d =&gt; toRemove.Any(r =&gt; d.CiscoMerakiDeviceId == r.CiscoMerakiDeviceId));
        return devices.Where(d =&gt; d.ConfigurationChanged || d.CiscoMerakiLastCheckIn != null);
    }

    private void SetAndLogCollisions(ImportCollisionChecker collisionChecker)
    {
        var collisions = collisionChecker.GetCollidingRecords();
        var systemPersonId = Alvao.API.Common.Person.GetSystem().iPersonId;
        foreach (var collision in collisions)
        {
            if (collisionChecker.ShouldLogCollision(collision.Key))
            {
                var log = collisionChecker.GetCollisionLog(collision.Key, systemPersonId);
                using var scope = AlvaoContext.GetConnectionScope();
                scope.BeginTransaction();
                CiscoMerakiConnectorRepository.InsertIntoObjectLog(log);
                CiscoMerakiConnectorRepository.SetImportConflict(collision.Key, true);
                scope.CommitTransaction();
            }
        }

        var noMoreColliding = collisionChecker.GetNodesToUnsetCollisionFlag();
        foreach (var nodeId in noMoreColliding)
        {
            CiscoMerakiConnectorRepository.SetImportConflict(nodeId, false);
        }
    }
    private int CheckAndAddPropertiesToTemplateByHostnameModel(string hostname, string model, CiscoMerakiDevice.Type type)
    {
        int classId = Alvao.API.AM.ObjectType.GetDeviceTypeId(hostname, model, 0);

        if (classId == 0)
            classId = CiscoMerakiDevice.GetFirstClassIdForDeviceType(type)
            ?? Alvao.API.Common.DbProperty.AMDefaultComputerClass;

        if (CheckedTemplatePropMap.TryAdd(classId, true))
        {
            if (!Alvao.API.AM.ObjectProperty.TemplateContains((int)classId, (tblKind.KindCode)KindCode.CiscoMerakiDeviceId))
            { 
                string kindCodes = string.Join(",", CiscoMerakiDevice.GetPropertyMappingForType(type).Select(x =&gt; ((int)x.Key).ToString()));
                CiscoMerakiConnectorRepository.AddMissingPropsToAmTemplateAndUnify((int)classId, kindCodes);
            }
        }
        return classId;
}
}

public class CiscoMerakiConnectorRepository
{
    private List&lt;int&gt; ScannerPropMapCache = new();

    public DateTime? GetCiscoMerakiLastCheckInDevicesDateTime()
    {
        using var scope = AlvaoContext.GetConnectionScope();
        return scope.Connection.ExecuteScalar&lt;DateTime?&gt;(@$"select top 1
            CiscoMerakiLastCheckIn
        from NodeCust nc
        join tblNode n on n.intNodeId = nc.NodeId
        where n.IsHidden = 0
            and isdate(substring([CiscoMerakiLastCheckIn],1,10))=1    --CiscoMeraki values: 2024-07-09T10:44:13.9769700Z
        order by [CiscoMerakiLastCheckIn] desc", null, transaction: scope.Transaction);
    }

    public tblNode GetDeviceNodeByAnotherProperty(int keyPropKindCode, string keyPropValue, bool onlyInComputers, bool isBiosSNInSourceEmpty = false)
    {
        using var scope = AlvaoContext.GetConnectionScope();
        return scope.Connection.Query&lt;tblNode&gt;(@$"
            declare @columnName nvarchar(255)
            set @columnName = (select k.ColumnName from tblKind k where k.intKindCode = @kindCode)

            declare @valueType nvarchar(255)
            set @valueType = (select DATA_TYPE from INFORMATION_SCHEMA.COLUMNS where TABLE_NAME = 'NodeCust' and COLUMN_NAME = @columnName)
            if (@valueType = 'varchar' or @valueType = 'nvarchar')
                set @valueType = @valueType + '(' + cast((select CHARACTER_MAXIMUM_LENGTH from INFORMATION_SCHEMA.COLUMNS where TABLE_NAME = 'NodeCust' and COLUMN_NAME = @columnName) as nvarchar(255)) + ')'


            declare @sql nvarchar(max)
            set @sql = '
            select top 1
                n.intNodeId, 
                n.lintClassId,
                n.ImportDuplicateAlert
               -- case when isdate(substring(nc.IntuneLastCheckIn,1,10))=1 then nc.IntuneLastCheckIn else null end as IntuneLastCheckIn
            from tblNode n
                {(onlyInComputers ? "join tblClass c on c.intClassId=n.lintClassId and c.bComputer=1" : "")}
                join ClassKind ck on ck.ClassId=n.lintClassId
                join tblKind k on k.intKindId=ck.KindId and k.intKindCode=@kindCode
                join NodeCust nc on nc.NodeId = n.intNodeId
            where n.IsHidden=0
                and ltrim(rtrim(nc.' + @columnName + '))=@value 
                {(keyPropKindCode == (int)KindCode.HostName &amp;&amp; !isBiosSNInSourceEmpty ? $@"
                    and (nc.BiosSerialNumber is null or nc.BiosSerialNumber in {CreateBlacklistedForDynamicSql(GetCachedBlacklistedValuesForKindcode((int)KindCode.BIOS_SN))}) " : "")}
            order by n.intNodeId asc'

            declare @prmsDecl nvarchar(max)
            set @prmsDecl = N'@kindCode int, @value ' + @valueType

            exec sp_executesql @sql, @prmsDecl, @kindCode, @value",
            new { kindCode = keyPropKindCode, value = (keyPropValue != null) ? keyPropValue.Trim() : null },
            transaction: scope.Transaction).FirstOrDefault();
    }

    private string CreateBlacklistedForDynamicSql(IEnumerable&lt;string&gt; blacklisted)
    {
        StringBuilder sb = new();
        sb.Append('(').Append(string.Join(',', blacklisted.Select(it =&gt; "''" + it + "''"))).Append(')');
        return sb.ToString();
    }

    public IEnumerable&lt;string&gt; GetCachedBlacklistedValuesForKindcode(int kindCode)
    {
        return CacheUtil.GetOrCreateCachedItem("blacklistedValuesForKindCode_" + kindCode, () =&gt; GetBlacklistedValuesForKindcode(kindCode), minutesToCache: 30);
    }

    public IEnumerable&lt;string&gt; GetBlacklistedValuesForKindcode(int kindCode)
    {
        var wbemProp = ObjectWbemProcess.GetWbemEquivalentNameAndClass(kindCode);
        if (wbemProp is null)
        {
            return Enumerable.Empty&lt;string&gt;();
        }
        using (var scope = AlvaoContext.GetConnectionScope())
        {
            return scope.Connection.Query&lt;string&gt;(
                @$"select 
                    txtPropValue
                from tblWbemObjectProcess
                where txtPropName = '{wbemProp.Name}'
                    and txtCLASS = '{wbemProp.Class}'
                ", new { }, scope.Transaction);
        }
    }

    public int GetOrCreateImportedObjectsFolder()
    {
        using var scope = AlvaoContext.GetConnectionScope();
        return scope.Connection.ExecuteScalar&lt;int&gt;($@"declare @nodeId int
        select top 1
            @nodeId=n.intNodeId
        from tblNode n
        where n.lintClassId=@classId
            and n.IsActive = 1

        if @nodeId is null
        begin
            insert tblNode (lintIconId,intState,txtName,lintClassId)
            select i.intIconId,128,d.txtText,d.lintClassId
            from tblDict d
                join tblIcon i on i.uid=@iconUid
            where d.lintClassId=@classId

            select @nodeId=scope_identity()

            insert tblNodeParent values (@nodeId,@nodeId)

            update nc
            set nc.[Name]=n.txtName
            from NodeCust nc
                join tblNode n on n.intNodeId=nc.NodeId
            where nc.NodeId=@nodeId
        end

        select @nodeId", new { classId = tblClass.ClassCode.ImportedObjects, iconUid = tblIcon.IconUid.Subnet }, transaction: scope.Transaction);
    }

    public int GetActualComputerCount()
    {
        using var scope = AlvaoContext.GetConnectionScope();
        return scope.Connection.ExecuteScalar&lt;int&gt;(@"select count(1) cnt 
        from tblNode n
        join tblClass c on c.intClassId=n.lintClassId
        where c.bComputer=1
        and n.IsActive = 1", transaction: scope.Transaction);
    }

    public tblNode CreateSimpleComputer(int classId, int parentNodeId, string hostname, int personId)
    {
        using var scope = AlvaoContext.GetConnectionScope();
        return scope.Connection.QueryFirst&lt;tblNode&gt;(@"declare @pcNodeId int, @setNodeId int
exec Internal.spCreateSimpleComputer @hostname, @parentNodeId, @personId, @classId, 1, @pcNodeId output, @setNodeId output
select @pcNodeId intNodeId, @classId lintClassId",
            new { hostname, parentNodeId, personId, classId }, transaction: scope.Transaction);
    }

    public tblNode CreateObjectByClass(int classId, int importedObjectsNodeFolderNodeId, int personId)
    {
        using var scope = AlvaoContext.GetConnectionScope();
        return scope.Connection.QueryFirst&lt;tblNode&gt;(@"declare @id int
exec @id=spCreateNodeFromTemplate @classId, '', @parentId, @personId
select @id intNodeId, @classId lintClassId", new { classId, parentId = importedObjectsNodeFolderNodeId, personId }, transaction: scope.Transaction);
    }

    public void AddMissingPropsToAmTemplateAndUnify(int classId, string kindCodes)
    {
        using var scope = AlvaoContext.GetConnectionScope();
        scope.Connection.Execute($@"
if object_id('tempdb..#kinds') is not null
	drop table #kinds

create table #kinds (id int primary key)
insert #kinds (id)
select id from dbo.ftCommaListToTableIds(@kindCodes)

--insert new to template
insert into ClassKind (KindId, ClassId)
select
	k.intKindId,
	@classId classId
from #kinds ids
	join tblKind k on k.intKindCode = ids.id
	left join ClassKind ck on ck.ClassId = @classId and k.intKindId = ck.KindId
where ck.KindId is null
", new { classId, kindCodes }, transaction: scope.Transaction);
    }

    public void CreateConnectorScannerIfNotExists()
    {
        using var scope = AlvaoContext.GetConnectionScope();
        scope.Connection.Execute($@"
            IF NOT EXISTS (
                SELECT 1 FROM Scanner WHERE id = {(int)ScannerId.CiscoMeraki}
            )
            BEGIN
                INSERT INTO Scanner (id, Name)
                VALUES ({(int)ScannerId.CiscoMeraki}, N'Cisco Meraki');
            END
            ");
    }

    public void ClearScannerPropertyLockoutTable()
    {
        using var scope = AlvaoContext.GetConnectionScope();
        scope.Connection.Execute($@"
delete spl
from ScannerPropertyLockout spl
left join ClassKind ck on ck.KindId=spl.LockingKindId and ck.ClassId=spl.ClassId
where spl.ScannerId={(int)ScannerId.CiscoMeraki}
and ck.KindId is null");
    }

    public void RecalculateScannerPropertyLockoutTable(CiscoMerakiDevice device, int classId)
    {
        if (classId == 0)
            return;

        if (ScannerPropMapCache.Contains(classId))
            return;

        KindCode lockingKindCode = KindCode.CiscoMerakiDeviceId;
        string lockedKindCodes = string.Join(",", CiscoMerakiDevice.GetPropertyMappingForType(device.DeviceType).Select(kv =&gt; kv.Key).Where(k =&gt; k != lockingKindCode).Select(k =&gt; ((int)k).ToString()));
        RecalculateScannerPropertyLockoutByClass(classId, (int)lockingKindCode, lockedKindCodes);

        ScannerPropMapCache.Add(classId);
    }

    public void RecalculateScannerPropertyLockoutByClass(int classId, int lockingKindCode, string lockedKindCodes)
    {
        using var scope = AlvaoContext.GetConnectionScope();
        scope.Connection.Execute($@"
if object_id('tempdb..#locked') is not null
    drop table #locked

create table #locked (id int primary key, notInTemplate bit default 0)
insert #locked (id)
select id from dbo.ftCommaListToTableIds(@lockedKindCodes)

merge into ScannerPropertyLockout tar using (
    select
        {(int)ScannerId.CiscoMeraki},
        @classId,
        locking.intKindId,
        locked.intKindId
    from tblKind locked
        join #locked l on l.id=locked.intKindCode
        join tblKind locking on locking.intKindCode=@lockingKindCode
) src (ScannerId,ClassId,LockingKindId,LockedKindId) on 
    tar.ScannerId=src.ScannerId 
    and tar.ClassId=src.ClassId 
    and tar.LockingKindId=src.LockingKindId
    and tar.LockedKindId=src.LockedKindId
when not matched by target then insert (ScannerId,ClassId,LockingKindId,LockedKindId) values (ScannerId,ClassId,LockingKindId,LockedKindId)
when not matched by source and tar.ClassId=@classId and tar.ScannerId={(int)ScannerId.CiscoMeraki} then delete;", new { classId, lockingKindCode, lockedKindCodes }, transaction: scope.Transaction);
    }

    public void InsertIntoObjectLog(tblLog log)
    {
        using var scope = AlvaoContext.GetConnectionScope();
        scope.Connection.Execute(@"
            insert into tblLog (lintNodeId, liLogPersonId, dteLog, txtLog)
            values (@lintNodeId, @liLogPersonId, @dteLog, @txtLog)   
        ", new { log.lintNodeId, log.liLogPersonId, log.dteLog, log.txtLog }, transaction: scope.Transaction);
    }

    public void SetImportConflict(int nodeId, bool hasConflict)
    {
        using var scope = AlvaoContext.GetConnectionScope();
        scope.Connection.Execute(@"
            update tblNode
            set ImportDuplicateAlert = @hasConflict
            where intNodeId = @nodeId
        ", new { nodeId, hasConflict }, transaction: scope.Transaction);
    }
}

public class CiscoMerakiClientService
{
    private string Token { get; set; }
    private readonly HttpClient client = new()
    {
        BaseAddress = new Uri(Settings.CiscoMerakiUrl)
    };

    public async Task&lt;IEnumerable&lt;CiscoMerakiDevice&gt;&gt; GetNewOrUpdatedDevices(DateTime? lastCheckInDateTime, CancellationToken cancellationToken)
    {
        try
        {
            client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
            client.DefaultRequestHeaders.Add("X-Cisco-Meraki-API-Key", DbProperty.CiscoMerakiToken);

            var orgResponse = await client.GetAsync("organizations");
            orgResponse.EnsureSuccessStatusCode();
            var organizations = JsonConvert.DeserializeObject&lt;List&lt;Organization&gt;&gt;(await orgResponse.Content.ReadAsStringAsync());
            if (organizations is null || organizations.Count == 0)
            {
                Logger.Log.Info("No organizations found.");
                return Enumerable.Empty&lt;CiscoMerakiDevice&gt;();
            }

            var allResults = new List&lt;CiscoMerakiDevice&gt;();
            foreach (var org in organizations)
            {
                // Step 1: Get device availabilities (covers all devices, provides status for last check-in)
                var availabilities = await GetAllDeviceAvailabilitiesAsync(org.Id);

                if (availabilities == null || availabilities.Count == 0)
                {
                    continue;
                }

                // Step 2: Get only devices with changed configuration
                var configChangedDevices = await GetDevicesUpdatedAfterAsync(org.Id, lastCheckInDateTime);
                var configChangedBySerial = configChangedDevices.ToDictionary(d =&gt; d.Serial, d =&gt; d);

                Logger.Log.Info($"{CiscoMerakiPeriodicAction.ConnectorName} Connector: Found {availabilities.Count} device(s), {configChangedDevices.Count} with configuration changes");

                foreach (var availability in availabilities)
                {
                    CiscoMerakiDevice ciscoDevice;
                    if (configChangedBySerial.TryGetValue(availability.Serial, out var fullDevice))
                    {
                        ciscoDevice = fullDevice.ToCiscoMerakiDevice();
                        ciscoDevice.ConfigurationChanged = true;
                    }
                    else
                    {
                        ciscoDevice = new CiscoMerakiDevice()
                        {
                            CiscoMerakiDeviceId = availability.Serial,
                            Hostname = availability.Name,
                            SerialNo = availability.Serial,
                            MacAddresses = availability.Mac,
                            DeviceType = Enum.TryParse&lt;CiscoMerakiDevice.Type&gt;(availability.ProductType, out var pt) ? pt : CiscoMerakiDevice.Type.wireless,
                            ConfigurationChanged = false
                        };
                    }
                    ciscoDevice.CiscoMerakiLastCheckIn = ComputeLastCheckInDateTime(availability.Status);
                    allResults.Add(ciscoDevice);
                }
            }

            return allResults;
        }
        catch (HttpRequestException ex)
        {
            Logger.Log.Error($"API request failed: {ex.Message}");
            throw new Exception($"Unsuccessful response from {CiscoMerakiPeriodicAction.ConnectorName}: {ex.StatusCode}, {ex.Message}");
        }
    }

    private static DateTime? ComputeLastCheckInDateTime(string status)
    {
        return status?.ToLowerInvariant() switch
        {
            "online" or "alerting" =&gt; DateTime.UtcNow,
            _ =&gt; null
        };
    }

    private async Task&lt;List&lt;DeviceAvailability&gt;&gt; GetAllDeviceAvailabilitiesAsync(string organizationId)
    {
        var allAvailabilities = new List&lt;DeviceAvailability&gt;();
        var url = $"organizations/{organizationId}/devices/availabilities?perPage=1000";

        while (!string.IsNullOrEmpty(url))
        {
            var response = await client.GetAsync(url);
            response.EnsureSuccessStatusCode();

            var availabilities = JsonConvert.DeserializeObject&lt;List&lt;DeviceAvailability&gt;&gt;(await response.Content.ReadAsStringAsync());
            if (availabilities is not null)
                allAvailabilities.AddRange(availabilities);

            url = GetNextPageUrl(response);
        }

        return allAvailabilities;
    }


    /// &lt;summary&gt;
    /// Retrieves devices for an organization whose configuration was updated
    /// after the specified timestamp, handling pagination.
    /// &lt;/summary&gt;
    private async Task&lt;List&lt;CiscoMerakiApiDevice&gt;&gt; GetDevicesUpdatedAfterAsync(string organizationId, DateTime? updatedAfter)
    {
        var allDevices = new List&lt;CiscoMerakiApiDevice&gt;();
        var url = $"organizations/{organizationId}/devices?perPage=1000";
        if (updatedAfter.HasValue)
        {
            var timestamp = updatedAfter.Value.ToUniversalTime().ToString("o");
            url += $"&amp;configurationUpdatedAfter={Uri.EscapeDataString(timestamp)}";
        }

        while (!string.IsNullOrEmpty(url))
        {
            var response = await client.GetAsync(url);
            response.EnsureSuccessStatusCode();

            var devices = JsonConvert.DeserializeObject&lt;List&lt;CiscoMerakiApiDevice&gt;&gt;(await response.Content.ReadAsStringAsync());
            if (devices is not null)
                allDevices.AddRange(devices);

            // Handle pagination via Link header
            url = GetNextPageUrl(response);
        }

        return allDevices;
    }

    /// &lt;summary&gt;
    /// Parses the "Link" response header to extract the next page URL, if any.
    /// &lt;/summary&gt;
    private static string GetNextPageUrl(HttpResponseMessage response)
    {
        if (!response.Headers.TryGetValues("Link", out var linkValues))
            return null;

        foreach (var link in linkValues)
        {
            // Format: &lt;https://...&gt;; rel=next
            var parts = link.Split(',');
            foreach (var part in parts)
            {
                if (part.Contains("rel=next"))
                {
                    var urlStart = part.IndexOf('&lt;') + 1;
                    var urlEnd = part.IndexOf('&gt;');
                    if (urlStart &gt; 0 &amp;&amp; urlEnd &gt; urlStart)
                        return part[urlStart..urlEnd];
                }
            }
        }

        return null;
    }

    public string CleanResponse(string response)
    {
        return response.Replace(":[]", ":null"); // when eg. hostDiscovery does not contain any data, api sends an empty array (why?!). Really hard to deserialize.
    }

}

// MODELS
public class Organization
{
    [JsonProperty("id")]
    public string Id { get; set; } = string.Empty;

    [JsonProperty("name")]
    public string Name { get; set; } = string.Empty;
}

public class DeviceAvailability
{
    [JsonProperty("serial")]
    public string Serial { get; set; } = string.Empty;

    [JsonProperty("name")]
    public string Name { get; set; }

    [JsonProperty("mac")]
    public string Mac { get; set; }

    [JsonProperty("networkId")]
    public string NetworkId { get; set; }

    [JsonProperty("productType")]
    public string ProductType { get; set; }

    [JsonProperty("status")]
    public string Status { get; set; }
}



public record CiscoMerakiResponse(string JsonRpc, IEnumerable&lt;CiscoMerakiApiDevice&gt; Result);
public record HostDiscovery(int HostId, long LastCheck);
public record Item(string Name, string LastValue);
public record Inventory(string SerialNo_a, string MacAddress_a, string MacAddress_b, string Os, string Model);

public class CiscoMerakiApiDevice : CiscoMerakiApiModel
{
    [JsonProperty("name")]
    public string Name { get; set; }

    [JsonProperty("serial")]
    public string Serial { get; set; } = string.Empty;

    [JsonProperty("mac")]
    public string Mac { get; set; }

    [JsonProperty("model")]
    public string Model { get; set; }

    [JsonProperty("networkId")]
    public string NetworkId { get; set; }

    [JsonProperty("firmware")]
    public string Firmware { get; set; }

    [JsonProperty("lanIp")]
    public string LanIp { get; set; }

    [JsonProperty("productType")]
    [JsonConverter(typeof(StringEnumConverter))]
    public CiscoMerakiDevice.Type? ProductType { get; set; }

    public override CiscoMerakiDevice ToCiscoMerakiDevice()
    {
        return new CiscoMerakiDevice()
        {
            CiscoMerakiDeviceId = Serial,
            CiscoMerakiLastCheckIn = null,
            Hostname = Name,
            OperatingSystem = Firmware,
            Model = Model,
            Manufacturer = "Cisco Meraki",
            SerialNo = Serial,
            MacAddresses = Mac,
            DeviceType = ProductType ?? CiscoMerakiDevice.Type.wireless
        };
    }

    public DateTime DateTimeFromEpoch(long ticks)
    {
        return new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc) + TimeSpan.FromSeconds(ticks);
    }
}

public class CiscoMerakiDevice
{
    public enum Type
    {
        wireless,
        appliance,
        @switch,
        systemsManager,
        camera,
        cellularGateway,
        sensor,
        wirelessController,
        campusGateway,
        secureConnect
    }
    public string CiscoMerakiDeviceId { get; set; }
    public DateTime? CiscoMerakiLastCheckIn { get; set; }
    public string Hostname { get; set; }
    public string OperatingSystem { get; set; }  // OS + OS version
    public string Model { get; set; }
    public string SerialNo { get; set; }
    public string MacAddresses { get; set; }
    public Type DeviceType { get; set; }
    public int? NodeId { get; set; }
    public string Manufacturer { get; internal set; }
    public bool ConfigurationChanged { get; set; }

    public static Dictionary&lt;tblClass.ClassCode, Type&gt; ClassMap = new()
    {
        { tblClass.ClassCode.NetworkDevice, Type.wireless}, 
    };

    public static int? GetFirstClassIdForDeviceType(Type type)
    {
        // we dont have fitting system object types
        return (int)ClassCode.NetworkDevice;
    }

    public Dictionary&lt;KindCode, string&gt; GetPropertyMapping()
    {
        return GetPropertyMappingForType(DeviceType);
    }

    public static Dictionary&lt;KindCode, string&gt; GetPropertyMappingForType(Type type)
    {
        Dictionary&lt;KindCode, string&gt; mapping = new()
        {
            {KindCode.CiscoMerakiDeviceId, nameof(CiscoMerakiDeviceId)},
            {KindCode.CiscoMerakiLastCheckIn, nameof(CiscoMerakiLastCheckIn)},
            {KindCode.HostName, nameof(Hostname)},
            {KindCode.OperatingSystem, nameof(OperatingSystem)},
            {KindCode.Model, nameof(Model)},
            {KindCode.SerialNo, nameof(SerialNo)},
            {KindCode.MACAddress, nameof(MacAddresses)},
            {KindCode.Manufacturer, nameof(Manufacturer)},
        };
        return mapping;
    }

    public override string ToString()
    {
        return $"CiscoMerakiDevice [\n" +
       $"  CiscoMerakiDeviceId={CiscoMerakiDeviceId},\n" +
       $"  CiscoMerakiLastCheckIn={CiscoMerakiLastCheckIn},\n" +
       $"  Hostname={Hostname},\n" +
       $"  OperatingSystem={OperatingSystem},\n" +
       $"  Model={Model},\n" +
       $"  SerialNo={SerialNo},\n" +
       $"  DeviceType={DeviceType},\n" +
       $"  MacAddresses={MacAddresses}\n" +
       $"]";
    }
}

public abstract class CiscoMerakiApiModel
{
    public abstract CiscoMerakiDevice ToCiscoMerakiDevice();

    protected string GetMacAddresses(params string[] MacAddresses)
    {
        return string.Join("; ", MacAddresses.Where(x =&gt; !string.IsNullOrEmpty(x)).Select(x =&gt; FormatMAC(x)));
    }

    private static string FormatMAC(string mac)
    {
        if (mac == null)
            return string.Empty;

        if (mac.Length != 12)
            return mac;


        return string.Format("{0}:{1}:{2}:{3}:{4}:{5}",
            mac.Substring(0, 2),
            mac.Substring(2, 2),
            mac.Substring(4, 2),
            mac.Substring(6, 2),
            mac.Substring(8, 2),
            mac.Substring(10, 2)).ToUpper();
    }

}

internal static class ObjectWbemProcess
{
    internal class WbemProp
    {
        public string Class { get; set; }
        public string Name { get; set; }

        public WbemProp(string txtclass, string txtname)
        {
            Class = txtclass;
            Name = txtname;
        }
    }
    private static Dictionary&lt;int, WbemProp&gt; KindToWbemMap = new()
        {
            {87, new("BIOS", "SerialNumber") }
        };
    internal static WbemProp GetWbemEquivalentNameAndClass(int kindCode)
    {
        WbemProp wbemProp = null;
        KindToWbemMap.TryGetValue(kindCode, out wbemProp);
        return wbemProp;
    }
}

/*Groups incoming devices based on Alvao object they matched to. Devices that do not exist in Alvao yet are matched to object ID 0.
     * Collisions are detected when two or more incoming devices match to the same Alvao object (ID &gt; 0) or when two or more incoming devices do not match to any Alvao object (ID = 0) but match each other based on BIOS SN or Hostname.
     */
public class ImportCollisionChecker
{
    private Dictionary&lt;int, CollisionInfo&gt; MatchMap { get; set; } = new Dictionary&lt;int, CollisionInfo&gt;();

    public void AddImportedInfo(int nodeId, CiscoMerakiDevice CiscoMerakiDevice, bool? errorSetBeforeImport = null)
    {
        var isFirstOccurence = !MatchMap.ContainsKey(nodeId);
        if (isFirstOccurence)
        {
            MatchMap.Add(nodeId, new CollisionInfo());
        }
        var collisionInfo = MatchMap[nodeId];
        if (errorSetBeforeImport is not null &amp;&amp; isFirstOccurence) collisionInfo.PresentBeforeImport = errorSetBeforeImport.Value;
        collisionInfo.CollidingObjects.Add(CiscoMerakiDevice);
    }

    public bool ShouldLogCollision(int nodeId)
    {
        return nodeId &gt; 0 &amp;&amp; HasCollision(nodeId) &amp;&amp; !HadCollisionBeforeImportStarted(nodeId);
    }

    public bool HasCollision(int nodeId)
    {
        MatchMap.TryGetValue(nodeId, out var collisionInfo);
        if (collisionInfo is null)
        {
            return false;
        }
        return collisionInfo.CollidingObjects.Count &gt;= 2;
    }

    public bool HadCollisionBeforeImportStarted(int nodeId)
    {
        MatchMap.TryGetValue(nodeId, out var collisionInfo);
        return collisionInfo?.PresentBeforeImport ?? false;
    }

    public tblLog GetCollisionLog(int nodeId, int personId)
    {
        return new tblLog()
        {
            lintNodeId = nodeId,
            liLogPersonId = personId,
            dteLog = DateTime.UtcNow,
            txtLog = GetCollisionLogMessage(nodeId)
        };
    }

    public Dictionary&lt;int, CollisionInfo&gt; GetCollidingRecords()
    {
        var collisionsOnExistingObjects = MatchMap.Where(it =&gt; it.Key &gt; 0 &amp;&amp; it.Value.CollidingObjects.Count &gt;= 2).ToDictionary(); // two incoming devices matched on one existing AM object (does not matter by which parameter)

        if (!MatchMap.ContainsKey(0))
        {
            return collisionsOnExistingObjects;
        }

        var collisionsOnObjectsToCreateByBiosSn = MatchMap[0].CollidingObjects.GroupBy(o =&gt; o.SerialNo).Where(gr =&gt; gr.Count() &gt;= 2).SelectMany(gr =&gt; gr); // two incoming devices do not have matching AM object yet, but they match each other based on BIOS SN or Hostname
        var collisionsOnObjectsToCreateByHostname = MatchMap[0].CollidingObjects.GroupBy(o =&gt; o.Hostname).Where(gr =&gt; gr.Count() &gt;= 2).SelectMany(gr =&gt; gr);

        var collisionsOnObjectsToCreate = collisionsOnObjectsToCreateByBiosSn.Union(collisionsOnObjectsToCreateByHostname).DistinctBy(d =&gt; d.CiscoMerakiDeviceId).ToList();
        return collisionsOnExistingObjects.Concat(new Dictionary&lt;int, CollisionInfo&gt;()
            {
                { 0, new CollisionInfo() { PresentBeforeImport = false, CollidingObjects = collisionsOnObjectsToCreate } }
            }).ToDictionary();
    }

    public IEnumerable&lt;int&gt; GetNodesToUnsetCollisionFlag()
    {
        return MatchMap.Where(it =&gt; it.Key &gt; 0 &amp;&amp; it.Value.CollidingObjects.Count == 1 &amp;&amp; it.Value.PresentBeforeImport).Select(it =&gt; it.Key);
    }

    private string GetCollisionLogMessage(int nodeId)
    {
        var sb = new StringBuilder();
        sb.Append("Duplicate devices were found during import. CiscoMeraki device IDs: ");

        var ids = MatchMap[nodeId].CollidingObjects.Select(o =&gt; o.CiscoMerakiDeviceId).ToList();
        var threeDots = "";
        if (ids.Count &gt; 3) threeDots = "...";

        var idsPart = string.Join(", ", ids.Take(3));
        sb.Append($"{idsPart}{threeDots}");
        return sb.ToString();
    }

    public class CollisionInfo
    {
        public bool PresentBeforeImport { get; set; } = false;
        public List&lt;CiscoMerakiDevice&gt; CollidingObjects { get; set; } = [];
    }
}</Code>
          <IsLibCode>false</IsLibCode>
        </Script>
        <Script id="4">
          <Name>OpenObjectInMerakiDashboardEntityCommand</Name>
          <Code>using System;
using System.Net.Http;
using System.Net.Http.Headers;
using Newtonsoft.Json;
using Alvao.API.Common.Model.CustomApps;
using Alvao.Apps.API;
using Alvao.Context;
using Dapper;
using Alvao.API.Common;

namespace Alvao.CustomApps.CiscoMerakiConnector;

public class OpenInMerakiDashboardObjectCommand : IEntityCommand
{
    public string Id { get; set; }
    public Entity Entity { get; set; }

    private static readonly HttpClient client = new HttpClient()
    {
        BaseAddress = new Uri(Settings.CiscoMerakiUrl)
    };

    public OpenInMerakiDashboardObjectCommand()
    {
        Id = "OpenInCiscoMerakiObjectCommand";
        Entity = Entity.Object;
    }

    public EntityCommandShowResult Show(int entityId, int personId)
    {
        int position = 2;
        string icon = "open_20_regular";
        string name = "Open in Meraki Dashboard";
        bool show = GetCiscoMerakiDeviceId(entityId) != null;
        return new EntityCommandShowResult(show, name, icon, position); 
    }

    public CommandResult Run(int entityId, int personId)
    {
        MessageType messageType = MessageType.None;
        string messageText = null;
        string serial = GetCiscoMerakiDeviceId(entityId);
        string navigateToUrl = GetDeviceDashboardUrl(serial);
        return new CommandResult(messageType, messageText, navigateToUrl);
    }

    private string GetDeviceDashboardUrl(string serial)
    {
        if (string.IsNullOrEmpty(serial))
            return null;

        var request = new HttpRequestMessage(HttpMethod.Get, $"devices/{serial}");
        request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
        request.Headers.Add("X-Cisco-Meraki-API-Key", DbProperty.CiscoMerakiToken);

        var response = client.Send(request);
        if (!response.IsSuccessStatusCode)
            return null;

        var content = response.Content.ReadAsStringAsync().Result;
        var device = JsonConvert.DeserializeObject&lt;MerakiDeviceResponse&gt;(content);
        return device?.Url;
    }

    private string GetCiscoMerakiDeviceId(int objectId)
    {
        if(string.IsNullOrEmpty(DbProperty.CiscoMerakiToken) || !Activation.IsModuleActivated(Alvao.Global.ModuleInfo.ModuleId.MonitoringConnectors))
             return null;

        using (var scope = AlvaoContext.GetConnectionScope())
            return scope.Connection.ExecuteScalar&lt;string&gt;($@"
                select CiscoMerakiDeviceId from NodeCust where NodeId=@objectId", new { objectId }, scope.Transaction);
    }

    private class MerakiDeviceResponse
    {
        [JsonProperty("url")]
        public string Url { get; set; }
    }
}</Code>
          <IsLibCode>false</IsLibCode>
        </Script>
        <Script id="7">
          <Name>Settings</Name>
          <Code>namespace Alvao.CustomApps.CiscoMerakiConnector;

public static class Settings
{
    public const string CiscoMerakiUrl = "https://api.meraki.com/api/v1/";
}</Code>
          <IsLibCode>true</IsLibCode>
        </Script>
      </Scripts>
    </Application>
  </Applications>
</AlvaoApplication>