// Container Routes Tests
// Validates container lifecycle operations (start/stop/restart/update/delete/discover)

const express = require('express');
const request = require('supertest');

// Build a test app with the containers route
function buildApp(mockDeps) {
  const app = express();
  app.use(express.json());

  const { errorMiddleware } = require('../../src/utilities/error-handler');
  const containersRouteFactory = require('../../routes/containers');
  app.use('/api/containers', containersRouteFactory(mockDeps));
  app.use(errorMiddleware);
  return app;
}

// Mock container factory
function mockContainer(overrides = {}) {
  return {
    inspect: jest.fn().mockResolvedValue({
      Id: 'abc123def456',
      Name: '/plex',
      Config: {
        Image: 'lscr.io/linuxserver/plex:latest',
        Env: ['TZ=America/New_York', 'PLEX_CLAIM='],
        ExposedPorts: { '32400/tcp': {} },
        Labels: { 'sami.managed': 'true', 'sami.app': 'plex', 'sami.subdomain': 'plex' }
      },
      Image: 'sha256:abc123',
      HostConfig: {
        Binds: ['E:/dockerdata/plex:/config'],
        PortBindings: { '32400/tcp': [{ HostPort: '32400' }] },
        RestartPolicy: { Name: 'unless-stopped' },
        NetworkMode: 'bridge',
        ExtraHosts: [],
        Privileged: false,
        CapAdd: null,
        CapDrop: null,
        Devices: [],
        LogConfig: { Type: 'json-file', Config: { 'max-size': '10m', 'max-file': '3' } },
        Memory: 2147483648,        // 2GB
        MemoryReservation: 1073741824, // 1GB
        NanoCpus: 2000000000,      // 2 cores
      },
      NetworkSettings: { Networks: { bridge: {} } }
    }),
    start: jest.fn().mockResolvedValue(),
    stop: jest.fn().mockResolvedValue(),
    restart: jest.fn().mockResolvedValue(),
    remove: jest.fn().mockResolvedValue(),
    update: jest.fn().mockResolvedValue(),
    logs: jest.fn().mockResolvedValue(Buffer.from('2026-04-05T10:00:00Z Plex server started')),
    ...overrides
  };
}

function createMockDeps(containerInstance) {
  const container = containerInstance || mockContainer();

  return {
    docker: {
      client: {
        getContainer: jest.fn().mockReturnValue(container),
        createContainer: jest.fn().mockResolvedValue({
          start: jest.fn().mockResolvedValue(),
          inspect: jest.fn().mockResolvedValue({ Id: 'new123' }),
          remove: jest.fn().mockResolvedValue(),
        }),
        getImage: jest.fn().mockReturnValue({
          inspect: jest.fn().mockResolvedValue({ RepoDigests: ['sha256:olddigest'] })
        }),
        listContainers: jest.fn().mockResolvedValue([]),
        pruneImages: jest.fn().mockResolvedValue({ SpaceReclaimed: 0 }),
      },
      pull: jest.fn().mockResolvedValue([]),
    },
    log: {
      info: jest.fn(),
      error: jest.fn(),
      debug: jest.fn(),
    },
    asyncHandler: (fn, name) => async (req, res, next) => {
      try { await fn(req, res, next); } catch (err) { next(err); }
    },
  };
}

describe('Container Routes — DashCaddy container lifecycle', () => {

  describe('POST /:id/start', () => {
    it('starts a stopped container', async () => {
      const deps = createMockDeps();
      const app = buildApp(deps);
      const res = await request(app).post('/api/containers/abc123/start');
      expect(res.status).toBe(200);
      expect(res.body.success).toBe(true);
      expect(res.body.message).toContain('started');
    });

    it('returns 404 for missing container', async () => {
      const container = mockContainer();
      const notFound = new Error('no such container');
      notFound.statusCode = 404;
      container.inspect.mockRejectedValue(notFound);
      const deps = createMockDeps(container);
      const app = buildApp(deps);

      const res = await request(app).post('/api/containers/missing123/start');
      expect(res.status).toBe(404);
    });
  });

  describe('POST /:id/stop', () => {
    it('stops a running container', async () => {
      const deps = createMockDeps();
      const app = buildApp(deps);
      const res = await request(app).post('/api/containers/abc123/stop');
      expect(res.status).toBe(200);
      expect(res.body.message).toContain('stopped');
    });
  });

  describe('POST /:id/restart', () => {
    it('restarts a container', async () => {
      const deps = createMockDeps();
      const app = buildApp(deps);
      const res = await request(app).post('/api/containers/abc123/restart');
      expect(res.status).toBe(200);
      expect(res.body.message).toContain('restarted');
    });
  });

  describe('GET /:id/logs', () => {
    it('returns last 100 log lines', async () => {
      const deps = createMockDeps();
      const app = buildApp(deps);
      const res = await request(app).get('/api/containers/abc123/logs');
      expect(res.status).toBe(200);
      expect(res.body.logs).toContain('Plex server started');
    });
  });

  describe('PUT /:id/resources', () => {
    it('updates memory and CPU limits', async () => {
      const container = mockContainer();
      const deps = createMockDeps(container);
      const app = buildApp(deps);

      const res = await request(app)
        .put('/api/containers/abc123/resources')
        .send({ memory: 4096, cpus: 4 });

      expect(res.status).toBe(200);
      expect(container.update).toHaveBeenCalledWith(
        expect.objectContaining({
          Memory: 4096 * 1024 * 1024,
          NanoCpus: 4 * 1e9,
        })
      );
    });

    it('sets 0 for unlimited', async () => {
      const container = mockContainer();
      const deps = createMockDeps(container);
      const app = buildApp(deps);

      const res = await request(app)
        .put('/api/containers/abc123/resources')
        .send({ memory: 0, cpus: 0 });

      expect(res.status).toBe(200);
      expect(container.update).toHaveBeenCalledWith(
        expect.objectContaining({
          Memory: 0,
          NanoCpus: 0,
        })
      );
    });
  });

  describe('GET /:id/resources', () => {
    it('returns current resource limits in human units', async () => {
      const deps = createMockDeps();
      const app = buildApp(deps);

      const res = await request(app).get('/api/containers/abc123/resources');
      expect(res.status).toBe(200);
      expect(res.body.memory).toBe(2048);           // 2GB in MB
      expect(res.body.memoryReservation).toBe(1024); // 1GB in MB
      expect(res.body.cpus).toBe(2);                 // 2 cores
    });
  });

  describe('DELETE /:id', () => {
    it('force-removes a container', async () => {
      const container = mockContainer();
      const deps = createMockDeps(container);
      const app = buildApp(deps);

      const res = await request(app).delete('/api/containers/abc123');
      expect(res.status).toBe(200);
      expect(container.remove).toHaveBeenCalledWith({ force: true });
    });
  });

  describe('GET /discover', () => {
    it('returns only sami.managed containers', async () => {
      const deps = createMockDeps();
      deps.docker.client.listContainers.mockResolvedValue([
        {
          Id: 'abc123', Names: ['/plex'], Image: 'linuxserver/plex',
          State: 'running', Status: 'Up 3 days',
          Labels: { 'sami.managed': 'true', 'sami.app': 'plex', 'sami.subdomain': 'plex' },
          Ports: [{ PrivatePort: 32400, PublicPort: 32400 }]
        },
        {
          Id: 'xyz789', Names: ['/random-container'], Image: 'nginx',
          State: 'running', Status: 'Up 1 hour',
          Labels: {},
          Ports: [{ PrivatePort: 80, PublicPort: 80 }]
        }
      ]);
      const app = buildApp(deps);

      const res = await request(app).get('/api/containers/discover');
      expect(res.status).toBe(200);
      expect(res.body.containers).toHaveLength(1);
      expect(res.body.containers[0].appTemplate).toBe('plex');
    });

    it('returns empty array when no managed containers', async () => {
      const deps = createMockDeps();
      const app = buildApp(deps);
      const res = await request(app).get('/api/containers/discover');
      expect(res.body.containers).toEqual([]);
    });
  });

  describe('POST /:id/update — error and edge cases', () => {
    it('preserves custom network mode (non-bridge/host/none)', async () => {
      const container = mockContainer();
      container.inspect.mockResolvedValue({
        Id: 'abc123', Name: '/plex',
        Config: { Image: 'plex:latest', Env: [], ExposedPorts: {}, Labels: {} },
        Image: 'sha256:abc',
        HostConfig: {
          Binds: [], PortBindings: {}, RestartPolicy: { Name: 'unless-stopped' },
          NetworkMode: 'my-custom-network',
          ExtraHosts: [], Privileged: false, CapAdd: null, CapDrop: null, Devices: []
        },
        NetworkSettings: { Networks: { 'my-custom-network': { IPAddress: '172.20.0.5' } } }
      });
      const newContainer = {
        start: jest.fn().mockResolvedValue(),
        inspect: jest.fn().mockResolvedValue({ Id: 'new123' })
      };
      const deps = createMockDeps(container);
      deps.docker.client.createContainer.mockResolvedValue(newContainer);

      const app = buildApp(deps);
      const res = await request(app).post('/api/containers/abc123/update');
      expect(res.status).toBe(200);

      const createCall = deps.docker.client.createContainer.mock.calls[0][0];
      expect(createCall.NetworkingConfig.EndpointsConfig['my-custom-network'])
        .toEqual({ IPAddress: '172.20.0.5' });
    });

    it('cleans up failed new container when start fails', async () => {
      const container = mockContainer();
      const newContainer = {
        start: jest.fn().mockRejectedValue(new Error('port already allocated')),
        remove: jest.fn().mockResolvedValue()
      };
      const deps = createMockDeps(container);
      deps.docker.client.createContainer.mockResolvedValue(newContainer);

      const app = buildApp(deps);
      const res = await request(app).post('/api/containers/abc123/update');

      expect(res.status).toBeGreaterThanOrEqual(500);
      expect(newContainer.remove).toHaveBeenCalledWith({ force: true });
    });

    it('handles new container remove cleanup failure gracefully', async () => {
      const container = mockContainer();
      const newContainer = {
        start: jest.fn().mockRejectedValue(new Error('start failed')),
        remove: jest.fn().mockRejectedValue(new Error('already gone'))
      };
      const deps = createMockDeps(container);
      deps.docker.client.createContainer.mockResolvedValue(newContainer);

      const app = buildApp(deps);
      const res = await request(app).post('/api/containers/abc123/update');
      expect(res.status).toBeGreaterThanOrEqual(500);
    });

    it('logs space reclaimed when image prune frees disk', async () => {
      const container = mockContainer();
      const deps = createMockDeps(container);
      deps.docker.client.pruneImages.mockResolvedValue({ SpaceReclaimed: 50 * 1024 * 1024 }); // 50MB

      const app = buildApp(deps);
      const res = await request(app).post('/api/containers/abc123/update');
      expect(res.status).toBe(200);
      expect(deps.log.info).toHaveBeenCalledWith(
        'docker',
        'Pruned dangling images after update',
        expect.objectContaining({ spaceReclaimed: '50MB' })
      );
    });

    it('continues if image prune fails', async () => {
      const container = mockContainer();
      const deps = createMockDeps(container);
      deps.docker.client.pruneImages.mockRejectedValue(new Error('prune failed'));

      const app = buildApp(deps);
      const res = await request(app).post('/api/containers/abc123/update');
      expect(res.status).toBe(200);
      expect(deps.log.debug).toHaveBeenCalledWith(
        'docker',
        'Image prune after update failed',
        expect.any(Object)
      );
    });

    it('ignores already-stopped error when stopping container', async () => {
      const container = mockContainer();
      container.stop.mockRejectedValue(new Error('container already stopped'));
      const deps = createMockDeps(container);

      const app = buildApp(deps);
      const res = await request(app).post('/api/containers/abc123/update');
      expect(res.status).toBe(200);
    });
  });

  describe('GET /:id/check-update', () => {
    it('reports no updates when local and new digests match', async () => {
      const container = mockContainer();
      const deps = createMockDeps(container);
      deps.docker.client.getImage.mockReturnValue({
        inspect: jest.fn().mockResolvedValue({ RepoDigests: ['sha256:samedigest'] })
      });
      deps.docker.pull.mockResolvedValue([]);

      const app = buildApp(deps);
      const res = await request(app).get('/api/containers/abc123/check-update');
      expect(res.status).toBe(200);
      expect(res.body.updateAvailable).toBe(false);
    });

    it('reports update available when downloads occur', async () => {
      const container = mockContainer();
      const deps = createMockDeps(container);
      deps.docker.pull.mockResolvedValue([
        { status: 'Downloading', id: 'layer1' },
        { status: 'Download complete', id: 'layer2' }
      ]);

      const app = buildApp(deps);
      const res = await request(app).get('/api/containers/abc123/check-update');
      expect(res.body.updateAvailable).toBe(true);
    });

    it('reports update available when digests differ', async () => {
      const container = mockContainer();
      const deps = createMockDeps(container);
      let callCount = 0;
      deps.docker.client.getImage.mockImplementation(() => {
        callCount++;
        return {
          inspect: jest.fn().mockResolvedValue({
            RepoDigests: callCount === 1
              ? ['sha256:olddigest']
              : ['sha256:newdigest']
          })
        };
      });
      deps.docker.pull.mockResolvedValue([]);

      const app = buildApp(deps);
      const res = await request(app).get('/api/containers/abc123/check-update');
      expect(res.body.updateAvailable).toBe(true);
    });

    it('returns false when pull throws (registry unreachable)', async () => {
      const container = mockContainer();
      const deps = createMockDeps(container);
      deps.docker.pull.mockRejectedValue(new Error('registry timeout'));

      const app = buildApp(deps);
      const res = await request(app).get('/api/containers/abc123/check-update');
      expect(res.status).toBe(200);
      expect(res.body.updateAvailable).toBe(false);
    });

    it('handles missing local repo digests gracefully', async () => {
      const container = mockContainer();
      const deps = createMockDeps(container);
      deps.docker.client.getImage.mockReturnValue({
        inspect: jest.fn().mockResolvedValue({ RepoDigests: null })
      });

      const app = buildApp(deps);
      const res = await request(app).get('/api/containers/abc123/check-update');
      expect(res.status).toBe(200);
      expect(res.body.currentDigest).toBeNull();
    });
  });

  describe('getVerifiedContainer error paths', () => {
    it('returns 404 when error message includes "no such container"', async () => {
      const container = mockContainer();
      container.inspect.mockRejectedValue(new Error('Error: no such container: missing'));
      const deps = createMockDeps(container);
      const app = buildApp(deps);

      const res = await request(app).post('/api/containers/missing/start');
      expect(res.status).toBe(404);
    });

    it('rethrows non-404 errors from inspect', async () => {
      const container = mockContainer();
      container.inspect.mockRejectedValue(new Error('docker daemon not running'));
      const deps = createMockDeps(container);
      const app = buildApp(deps);

      const res = await request(app).post('/api/containers/abc123/start');
      expect(res.status).toBeGreaterThanOrEqual(500);
    });
  });

  describe('PUT /:id/resources — partial updates', () => {
    it('updates only memory when cpus omitted', async () => {
      const container = mockContainer();
      const deps = createMockDeps(container);
      const app = buildApp(deps);

      const res = await request(app)
        .put('/api/containers/abc123/resources')
        .send({ memory: 2048 });

      expect(res.status).toBe(200);
      const call = container.update.mock.calls[0][0];
      expect(call.Memory).toBe(2048 * 1024 * 1024);
      expect(call.NanoCpus).toBeUndefined();
    });

    it('updates only cpus when memory omitted', async () => {
      const container = mockContainer();
      const deps = createMockDeps(container);
      const app = buildApp(deps);

      const res = await request(app)
        .put('/api/containers/abc123/resources')
        .send({ cpus: 1.5 });

      expect(res.status).toBe(200);
      const call = container.update.mock.calls[0][0];
      expect(call.NanoCpus).toBe(1.5 * 1e9);
      expect(call.Memory).toBeUndefined();
    });
  });

  describe('GET /:id/resources — zero values', () => {
    it('returns 0 when no limits set', async () => {
      const container = mockContainer();
      container.inspect.mockResolvedValue({
        Id: 'abc', Name: '/test', Config: { Image: 'test:latest' },
        HostConfig: { Memory: 0, MemoryReservation: 0, NanoCpus: 0 }
      });
      const deps = createMockDeps(container);
      const app = buildApp(deps);

      const res = await request(app).get('/api/containers/abc123/resources');
      expect(res.body.memory).toBe(0);
      expect(res.body.memoryReservation).toBe(0);
      expect(res.body.cpus).toBe(0);
    });
  });

  describe('GET /discover — pagination', () => {
    it('paginates results when paginate query params provided', async () => {
      const containers = Array.from({ length: 25 }, (_, i) => ({
        Id: `id${i}`,
        Names: [`/svc${i}`],
        Image: 'test:latest',
        State: 'running',
        Status: 'Up',
        Labels: { 'sami.managed': 'true', 'sami.app': 'test', 'sami.subdomain': `svc${i}` },
        Ports: []
      }));
      const deps = createMockDeps();
      deps.docker.client.listContainers.mockResolvedValue(containers);
      const app = buildApp(deps);

      const res = await request(app).get('/api/containers/discover?page=1&limit=10');
      expect(res.status).toBe(200);
      expect(res.body.containers.length).toBeLessThanOrEqual(10);
    });
  });

  describe('DashCaddy-specific scenarios', () => {
    it('Plex container: verifies correct resource read (2GB, 2 cores)', async () => {
      const deps = createMockDeps();
      const app = buildApp(deps);
      const res = await request(app).get('/api/containers/abc123/resources');
      expect(res.body.memory).toBe(2048);
      expect(res.body.cpus).toBe(2);
    });

    it('container update: preserves Env, PortBindings, RestartPolicy', async () => {
      const container = mockContainer();
      const newContainer = {
        start: jest.fn().mockResolvedValue(),
        inspect: jest.fn().mockResolvedValue({ Id: 'new456' }),
        remove: jest.fn().mockResolvedValue(),
      };
      const deps = createMockDeps(container);
      deps.docker.client.createContainer.mockResolvedValue(newContainer);
      const app = buildApp(deps);

      const res = await request(app).post('/api/containers/abc123/update');
      expect(res.status).toBe(200);

      const createCall = deps.docker.client.createContainer.mock.calls[0][0];
      expect(createCall.Env).toContain('TZ=America/New_York');
      expect(createCall.HostConfig.PortBindings['32400/tcp']).toEqual([{ HostPort: '32400' }]);
      expect(createCall.HostConfig.RestartPolicy).toEqual({ Name: 'unless-stopped' });
    });
  });
});
