import type { PreflightContext, PreflightFinding, PreflightSeverity, NodePortBinding } from './types';
import type { EffService, EffPortSpec, EffectiveModel } from './effectiveModel';
import type { ExposureIntent } from '../network/types';
import { isLoopback, runtimeResourceName } from '../network/normalize';
import { classifyMissingExternalNetworks } from '../network/missingExternalNetworks';

/** Higher number = more severe. Used to derive a run's overall status. */
export const SEVERITY_RANK: Record<PreflightSeverity, number> = { info: 0, warning: 1, high: 2, blocker: 3 };

/**
 * Rule IDs that are informational notes, not issue findings. They appear in the
 * report for context but do not affect All Clear, active severity, or Update Guard.
 */
export const PREFLIGHT_NOTE_RULE_IDS: ReadonlySet<string> = new Set([
  'healthcheck-inherited',
  'docker-socket-proxy-client',
]);

export function isPreflightNoteFinding(ruleId: string): boolean {
  return PREFLIGHT_NOTE_RULE_IDS.has(ruleId);
}

/** The one rule whose message doubles as the report's render error. Shared so the
 *  service that reconstructs renderError from it cannot drift from the rule id. */
export const RENDER_FAILED_RULE_ID = 'render-failed';

export interface PreflightRule {
  id: string;
  run(ctx: PreflightContext): PreflightFinding[];
}

// ----- shared helpers -------------------------------------------------------

const MAX_RANGE = 256; // cap range expansion so an adversarial 1-65535 spec can't blow up

function isAllInterfaces(ip: string): boolean {
  return ip === '' || ip === '0.0.0.0' || ip === '::' || ip === '[::]';
}

function interfaceOverlap(a: string, b: string): boolean {
  return isAllInterfaces(a) || isAllInterfaces(b) || a === b;
}

function portsOf(spec: EffPortSpec): number[] {
  const end = Math.min(spec.endPort, spec.startPort + MAX_RANGE - 1);
  const out: number[] = [];
  for (let p = spec.startPort; p <= end; p++) out.push(p);
  return out;
}

function specLabel(spec: EffPortSpec): string {
  return spec.startPort === spec.endPort ? `${spec.startPort}` : `${spec.startPort}-${spec.endPort}`;
}

/** True when the image reference resolves to a moving `latest` tag. */
function usesLatestTag(image: string): boolean {
  if (image.includes('@sha256:')) return false; // digest-pinned
  const lastSlash = image.lastIndexOf('/');
  const lastColon = image.lastIndexOf(':');
  if (lastColon > lastSlash) return image.slice(lastColon + 1) === 'latest';
  return true; // no tag → implicit latest
}

const UID_GID_KEYS = new Set(['PUID', 'PGID', 'UID', 'GID']);
function hasUidGidSignal(svc: EffService): boolean {
  return svc.user !== undefined || svc.envKeys.some(k => UID_GID_KEYS.has(k));
}

function pathIsDockerSocket(path: string | undefined): boolean {
  return path !== undefined && path.includes('docker.sock');
}

function mountTouchesDockerSocket(source: string | undefined, target: string | undefined): boolean {
  return pathIsDockerSocket(source) || pathIsDockerSocket(target);
}

/** Bind mounts that touch the Docker socket (from the storage inventory). */
function dockerSocketBinds(svc: EffService) {
  return (svc.storageMounts ?? []).filter(m =>
    m.type === 'bind' && mountTouchesDockerSocket(m.source, m.target));
}

function mountsDockerSocket(svc: EffService): boolean {
  if (svc.binds.some(b => mountTouchesDockerSocket(b.source, b.target))) return true;
  return dockerSocketBinds(svc).length > 0;
}

function hasReadOnlyDockerSocket(svc: EffService): boolean {
  return dockerSocketBinds(svc).some(m => m.readOnly);
}

/**
 * True when any socket mount is writable. Deliberately not the negation of
 * `hasReadOnlyDockerSocket`: a service can mount the socket twice, and one
 * read-only mount does not make a second writable one safe.
 */
function hasWritableDockerSocket(svc: EffService): boolean {
  return dockerSocketBinds(svc).some(m => !m.readOnly);
}

const SOCKET_PROXY_IMAGE_HINTS = [
  'tecnativa/docker-socket-proxy',
  'lscr.io/linuxserver/socket-proxy',
  'docker-socket-proxy',
] as const;

const MUTATING_PROXY_API_FLAGS = new Set(['POST', 'DELETE']);

function hasSocketProxyImage(svc: EffService): boolean {
  const image = svc.image?.toLowerCase();
  return image !== undefined && SOCKET_PROXY_IMAGE_HINTS.some(h => image.includes(h));
}

function hasSocketProxyNameHint(svc: EffService): boolean {
  // Covers `socket-proxy`, `docker-socket-proxy`, and `_`/`.` separated variants.
  return `${svc.name} ${svc.containerName ?? ''}`
    .toLowerCase()
    .replace(/[_.]/g, '-')
    .includes('socket-proxy');
}

/** Names a dependent service could use to reach this proxy on a shared network. */
function proxyReachableNames(svc: EffService): string[] {
  return [svc.name, svc.containerName, ...svc.networks.flatMap(n => n.aliases)]
    .filter((n): n is string => n !== undefined)
    .map(n => n.toLowerCase());
}

/**
 * Dedicated Docker socket proxy. A known image is an artifact identity, so it
 * stands on its own. A service NAME is free text the author controls, so it
 * only counts alongside an observable fact: a read-only socket or an enabled
 * API group flag (value exactly `1`). Without a name or image hint, both are
 * required. Prefer false negatives here, since a miss keeps the high
 * direct-mount finding.
 */
function isSocketProxyService(svc: EffService): boolean {
  if (!mountsDockerSocket(svc)) return false;
  if (hasSocketProxyImage(svc)) return true;
  const readOnly = hasReadOnlyDockerSocket(svc);
  // Only flags whose rendered value is exactly `1` are counted; key presence alone is not.
  const apiKeyCount = (svc.enabledProxyApiFlags ?? []).length;
  if (hasSocketProxyNameHint(svc)) return readOnly || apiKeyCount >= 1;
  return readOnly && apiKeyCount >= 2;
}

function socketProxyServices(model: EffectiveModel): EffService[] {
  return model.services.filter(isSocketProxyService);
}

/**
 * True unless every network the proxy joins is declared `internal: true`. A
 * service with no explicit membership lands on the implicit default network,
 * and a network the model does not describe cannot be shown to be internal, so
 * both count as non-internal.
 */
function proxyAttachesNonInternalNetwork(svc: EffService, model: EffectiveModel): boolean {
  if (svc.networks.length === 0) return true;
  return svc.networks.some(membership => model.networks[membership.key]?.internal !== true);
}

// ----- rules ----------------------------------------------------------------

const renderFailed: PreflightRule = {
  id: RENDER_FAILED_RULE_ID,
  run(ctx) {
    if (ctx.renderable) return [];
    return [{
      ruleId: RENDER_FAILED_RULE_ID,
      severity: 'blocker',
      title: 'Compose model could not be rendered',
      message: ctx.renderError ?? 'docker compose config failed to produce an effective model.',
      remediation: 'Fix the reported error. Sencho cannot validate a stack it cannot render.',
    }];
  },
};

const envUnset: PreflightRule = {
  id: 'env-unset',
  run(ctx) {
    return ctx.unsetEnvVars.map(name => ({
      ruleId: 'env-unset',
      severity: 'high' as const,
      title: `Unset variable ${name}`,
      message: `"${name}" is referenced by the Compose model but is not set in the environment or any consulted env file. Compose substitutes an empty string, which often breaks the container silently.`,
      sourcePath: name,
      remediation: `Define ${name} in a .env or env_file, or give it a default with \${${name}:-value}. If the value is a literal secret or hash containing \`$\`, escape \`$\` as \`$$\` in Compose YAML or single-quote the value in an env file.`,
    }));
  },
};

const LITERAL_DOLLAR_REMEDIATION =
  'If this was intended as a Compose variable, define it in the project environment or give it a default. '
  + 'If it is part of a literal secret or hash, escape literal dollar signs as `$$` in Compose YAML or single-quote the value in an env file.';

const envLiteralDollar: PreflightRule = {
  id: 'env-literal-dollar',
  run(ctx) {
    return ctx.literalDollarWarnings.map(w => {
      const likelySecret = w.likelySecret;
      const title = likelySecret
        ? 'Literal dollar sign in likely secret value may be interpolated'
        : 'Literal dollar sign in environment value may be interpolated';
      const keyHint = w.envKey ? ` for "${w.envKey}"` : '';
      return {
        ruleId: 'env-literal-dollar',
        severity: 'high' as const,
        title,
        message: `Compose treated a literal $ sequence inside an environment value${keyHint} as variable interpolation and may substitute an empty string for part of the value.`,
        sourcePath: w.envKey,
        remediation: LITERAL_DOLLAR_REMEDIATION,
        service: w.service,
      };
    });
  },
};

const envFileMissing: PreflightRule = {
  id: 'env-file-missing',
  run(ctx) {
    return ctx.missingEnvFiles.map(f => ({
      ruleId: 'env-file-missing',
      severity: 'high' as const,
      title: `Missing env file ${f.rawPath}`,
      message: `The Compose file declares env_file "${f.rawPath}"${f.services.length ? ` for service ${f.services.join(', ')}` : ''}, but no such file exists in the stack directory. Compose fails to start the stack when a required env_file is absent.`,
      sourcePath: f.rawPath,
      remediation: `Create ${f.rawPath} in the stack directory, fix the path, or mark the entry optional with "required: false".`,
      service: f.services[0],
    }));
  },
};

const portConflictNode: PreflightRule = {
  id: 'port-conflict-node',
  run(ctx) {
    if (!ctx.model || !ctx.nodeStateAvailable) return [];
    const byPort = new Map<number, NodePortBinding[]>();
    for (const b of ctx.nodePorts) {
      const list = byPort.get(b.publishedPort);
      if (list) list.push(b); else byPort.set(b.publishedPort, [b]);
    }
    const findings: PreflightFinding[] = [];
    for (const svc of ctx.model.services) {
      for (const spec of svc.ports) {
        for (const port of portsOf(spec)) {
          const clash = (byPort.get(port) ?? []).find(b =>
            b.protocol === spec.protocol && interfaceOverlap(spec.hostIp, b.ip) && b.stack !== ctx.stackName);
          if (!clash) continue;
          const owner = clash.stack ? `stack "${clash.stack}"` : 'another container';
          findings.push({
            ruleId: 'port-conflict-node',
            severity: 'blocker',
            title: `Host port ${port} is already in use`,
            message: `Service "${svc.name}" publishes ${port}/${spec.protocol}, but ${owner} already binds that port on this node. The deploy will fail.`,
            sourcePath: svc.name,
            service: svc.name,
            remediation: 'Stop the conflicting workload or publish a different host port.',
          });
          break; // one finding per service+spec is enough
        }
      }
    }
    return findings;
  },
};

const portConflictInternal: PreflightRule = {
  id: 'port-conflict-internal',
  run(ctx) {
    if (!ctx.model) return [];
    const claims = new Map<string, { service: string; hostIp: string }[]>();
    for (const svc of ctx.model.services) {
      for (const spec of svc.ports) {
        for (const port of portsOf(spec)) {
          const key = `${port}/${spec.protocol}`;
          const list = claims.get(key);
          if (list) list.push({ service: svc.name, hostIp: spec.hostIp });
          else claims.set(key, [{ service: svc.name, hostIp: spec.hostIp }]);
        }
      }
    }
    const findings: PreflightFinding[] = [];
    for (const [key, list] of claims) {
      const services = [...new Set(list.map(c => c.service))];
      if (services.length < 2) continue;
      const overlapping = list.some((a, i) => list.slice(i + 1).some(b => b.service !== a.service && interfaceOverlap(a.hostIp, b.hostIp)));
      if (!overlapping) continue;
      findings.push({
        ruleId: 'port-conflict-internal',
        severity: 'blocker',
        title: `Two services publish ${key}`,
        message: `Services ${services.map(s => `"${s}"`).join(' and ')} both publish host port ${key}. Only one can bind it, so the deploy will fail.`,
        remediation: 'Give each service a distinct host port.',
      });
    }
    return findings;
  },
};

const portExposedAllInterfaces: PreflightRule = {
  id: 'port-exposed-all-interfaces',
  run(ctx) {
    if (!ctx.model) return [];
    const findings: PreflightFinding[] = [];
    for (const svc of ctx.model.services) {
      for (const spec of svc.ports) {
        if (!isAllInterfaces(spec.hostIp)) continue;
        findings.push({
          ruleId: 'port-exposed-all-interfaces',
          severity: 'high',
          title: `Port ${specLabel(spec)} exposed on all interfaces`,
          message: `Service "${svc.name}" publishes ${specLabel(spec)}/${spec.protocol} on all interfaces (0.0.0.0), so it is reachable from every network the host is attached to.`,
          sourcePath: svc.name,
          service: svc.name,
          remediation: `Bind to a specific interface, e.g. 127.0.0.1:${spec.startPort}, if this should not be public.`,
        });
      }
    }
    return findings;
  },
};

const bindPathMissing: PreflightRule = {
  id: 'bind-path-missing',
  run(ctx) {
    return ctx.bindChecks
      .filter(b => b.withinBase && !b.exists)
      .map(b => ({
        ruleId: 'bind-path-missing',
        severity: 'high' as const,
        title: 'Bind mount path is missing',
        message: `The host path "${b.source}" for service "${b.service}" does not exist. Docker will create it as a root-owned directory on deploy, which often leaves the container unable to write to it.`,
        sourcePath: b.source,
        service: b.service,
        remediation: 'Create the directory with the ownership the container expects before deploying.',
      }));
  },
};

const bindPathPermission: PreflightRule = {
  id: 'bind-path-permission',
  run(ctx) {
    if (!ctx.model || ctx.platform === 'win32') return [];
    const svcByName = new Map(ctx.model.services.map(s => [s.name, s]));
    const findings: PreflightFinding[] = [];
    for (const b of ctx.bindChecks) {
      if (!b.withinBase || !b.exists || b.ownerUid !== 0) continue;
      const svc = svcByName.get(b.service);
      if (!svc || !hasUidGidSignal(svc)) continue;
      findings.push({
        ruleId: 'bind-path-permission',
        severity: 'warning',
        title: 'Bind mount may have wrong ownership',
        message: `The host path "${b.source}" is owned by root, but service "${b.service}" runs as a non-root user. It may not be able to write there.`,
        sourcePath: b.source,
        service: b.service,
        remediation: 'chown the path to the UID/GID the container runs as.',
      });
    }
    return findings;
  },
};

const dockerSocketMount: PreflightRule = {
  id: 'docker-socket-mount',
  run(ctx) {
    if (!ctx.model) return [];
    const proxies = socketProxyServices(ctx.model);
    // When the stack already runs a proxy, point at it rather than suggesting
    // the user adopt a mitigation they have implemented.
    const remediation = proxies.length > 0
      ? `This stack already runs a socket proxy (${proxies.map(p => `"${p.name}"`).join(', ')}); route this service through it instead of mounting docker.sock directly.`
      : 'Avoid direct socket mounts when possible; consider using a scoped socket proxy.';
    return ctx.model.services
      .filter(svc => mountsDockerSocket(svc) && !isSocketProxyService(svc))
      .map(svc => ({
        ruleId: 'docker-socket-mount',
        severity: 'high' as const,
        title: 'Docker socket mounted',
        message: `Service "${svc.name}" mounts the Docker socket directly, granting broad control over the Docker host.`,
        sourcePath: svc.name,
        service: svc.name,
        remediation,
      }));
  },
};

const dockerSocketProxyWritable: PreflightRule = {
  id: 'docker-socket-proxy-writable',
  run(ctx) {
    if (!ctx.model) return [];
    return socketProxyServices(ctx.model)
      .filter(hasWritableDockerSocket)
      .map(svc => ({
        ruleId: 'docker-socket-proxy-writable',
        severity: 'high' as const,
        title: 'Docker socket proxy mounts the socket read-write',
        message: `Service "${svc.name}" looks like a Docker socket proxy but mounts the Docker socket read-write, so a compromise of the proxy grants full control over the Docker host.`,
        sourcePath: svc.name,
        service: svc.name,
        remediation: 'Mount the socket read-only (append `:ro` to the bind) and let the proxy restrict which API groups dependents can reach.',
      }));
  },
};

const dockerSocketProxy: PreflightRule = {
  id: 'docker-socket-proxy',
  run(ctx) {
    if (!ctx.model) return [];
    return socketProxyServices(ctx.model).map(svc => ({
      ruleId: 'docker-socket-proxy',
      severity: 'info' as const,
      title: 'Docker socket proxy detected',
      message: `Service "${svc.name}" intentionally mounts the Docker socket because it is acting as a socket proxy. Verify that only trusted services can reach the proxy and that the enabled Docker API groups are limited to what dependent services require.`,
      sourcePath: svc.name,
      service: svc.name,
      remediation: 'Keep the proxy on an internal network, avoid publishing its API port to the host, and enable only the API groups dependents need.',
    }));
  },
};

const dockerSocketProxyPublished: PreflightRule = {
  id: 'docker-socket-proxy-published',
  run(ctx) {
    if (!ctx.model) return [];
    return socketProxyServices(ctx.model)
      .filter(svc => svc.ports.length > 0)
      .map(svc => ({
        ruleId: 'docker-socket-proxy-published',
        severity: 'high' as const,
        title: 'Docker socket proxy publishes a host port',
        message: `Service "${svc.name}" is a Docker socket proxy and publishes ${svc.ports.map(specLabel).join(', ')} to the host, which can make the proxy reachable beyond the Compose network.`,
        sourcePath: svc.name,
        service: svc.name,
        remediation: 'Remove host port mappings from the proxy and let dependents reach it only over an internal Compose network.',
      }));
  },
};

const dockerSocketProxyMutating: PreflightRule = {
  id: 'docker-socket-proxy-mutating',
  run(ctx) {
    if (!ctx.model) return [];
    return socketProxyServices(ctx.model).flatMap(svc => {
      const mutating = (svc.enabledProxyApiFlags ?? []).filter(f => MUTATING_PROXY_API_FLAGS.has(f));
      if (mutating.length === 0) return [];
      return [{
        ruleId: 'docker-socket-proxy-mutating',
        severity: 'warning' as const,
        title: 'Docker socket proxy allows mutating API access',
        message: `Service "${svc.name}" enables ${mutating.join(' and ')} on the Docker socket proxy, which permits write and delete operations through the proxy.`,
        sourcePath: svc.name,
        service: svc.name,
        remediation: 'Disable POST and DELETE on the proxy unless dependents require mutating Docker API calls.',
      }];
    });
  },
};

const dockerSocketProxyExposure: PreflightRule = {
  id: 'docker-socket-proxy-exposure',
  run(ctx) {
    const model = ctx.model;
    if (!model) return [];
    return socketProxyServices(model)
      .filter(svc => proxyAttachesNonInternalNetwork(svc, model))
      .map(svc => ({
        ruleId: 'docker-socket-proxy-exposure',
        severity: 'warning' as const,
        title: 'Docker socket proxy on a non-internal network',
        message: `Service "${svc.name}" is a Docker socket proxy attached to at least one network that is not marked internal, which widens who can reach the proxied Docker API.`,
        sourcePath: svc.name,
        service: svc.name,
        remediation: 'Attach the proxy only to internal Compose networks used by trusted dependents.',
      }));
  },
};

const dockerSocketProxyClient: PreflightRule = {
  id: 'docker-socket-proxy-client',
  run(ctx) {
    if (!ctx.model) return [];
    const proxies = socketProxyServices(ctx.model);
    if (proxies.length === 0) return [];

    // Per-proxy correlation: the client must name a reachable identity of a
    // specific proxy AND share a network with that same proxy. Aggregate
    // name/network sets would false-positive when a client on proxy-A's
    // network points at unreachable proxy-B.
    //
    // Hosts come from `DOCKER_HOST` and from `tcp://` endpoints on command /
    // entrypoint (how Traefik and friends point at a proxy). Presence of the
    // `DOCKER_HOST` key alone is not enough; only a tcp host that matches.
    function isClientOf(svc: EffService, proxy: EffService): boolean {
      const names = new Set(proxyReachableNames(proxy));
      if (!svc.dockerEndpointHosts.some(host => names.has(host))) return false;
      // Empty membership = implicit default network. Only match when both are on it.
      if (svc.networks.length === 0 || proxy.networks.length === 0) {
        return svc.networks.length === 0 && proxy.networks.length === 0;
      }
      const proxyNets = new Set(proxy.networks.map(n => n.key));
      return svc.networks.some(n => proxyNets.has(n.key));
    }

    return ctx.model.services
      .filter(svc => !mountsDockerSocket(svc) && proxies.some(proxy => isClientOf(svc, proxy)))
      .map(svc => ({
        ruleId: 'docker-socket-proxy-client',
        severity: 'info' as const,
        title: 'Docker API access routed through socket proxy',
        message: `Service "${svc.name}" does not mount docker.sock directly and appears to use a Docker socket proxy instead.`,
        sourcePath: svc.name,
        service: svc.name,
      }));
  },
};

const privileged: PreflightRule = {
  id: 'privileged',
  run(ctx) {
    if (!ctx.model) return [];
    return ctx.model.services.filter(s => s.privileged).map(s => ({
      ruleId: 'privileged',
      severity: 'high' as const,
      title: 'Privileged container',
      message: `Service "${s.name}" runs with privileged: true, which disables most container isolation.`,
      sourcePath: s.name,
      service: s.name,
      remediation: 'Drop privileged and grant only the specific capabilities the service needs.',
    }));
  },
};

const networkModeHost: PreflightRule = {
  id: 'network-mode-host',
  run(ctx) {
    if (!ctx.model) return [];
    return ctx.model.services.filter(s => s.networkMode === 'host').map(s => ({
      ruleId: 'network-mode-host',
      severity: 'high' as const,
      title: 'Host network mode',
      message: `Service "${s.name}" uses network_mode: host. Its ports bypass Docker's network isolation and ignore published-port mappings.`,
      sourcePath: s.name,
      service: s.name,
      remediation: 'Use bridge networking with explicit published ports unless host mode is required.',
    }));
  },
};

const uidGidRisk: PreflightRule = {
  id: 'uid-gid-risk',
  run(ctx) {
    if (!ctx.model) return [];
    // Only for binds whose ownership Sencho cannot verify (outside the compose
    // base); within-base root-owned binds are covered by bind-path-permission.
    const unverifiableByService = new Set(ctx.bindChecks.filter(b => !b.withinBase).map(b => b.service));
    return ctx.model.services
      .filter(s => hasUidGidSignal(s) && unverifiableByService.has(s.name))
      .map(s => ({
        ruleId: 'uid-gid-risk',
        severity: 'warning' as const,
        title: 'Check UID/GID alignment',
        message: `Service "${s.name}" sets a user/UID and mounts host paths Sencho cannot inspect. Mismatched ownership between the host path and the container user is a common cause of permission errors.`,
        sourcePath: s.name,
        service: s.name,
        remediation: 'Ensure the bind-mount paths are owned by the UID/GID the container runs as.',
      }));
  },
};

const imageLatest: PreflightRule = {
  id: 'image-latest',
  run(ctx) {
    if (!ctx.model) return [];
    return ctx.model.services
      .filter(s => s.image !== undefined && usesLatestTag(s.image))
      .map(s => ({
        ruleId: 'image-latest',
        severity: 'warning' as const,
        title: 'Image uses a moving tag',
        message: `Service "${s.name}" uses "${s.image}", which resolves to a moving latest tag. Deploys are not reproducible and can change under you.`,
        sourcePath: s.name,
        service: s.name,
        remediation: 'Pin a specific version tag.',
      }));
  },
};

const noRestartPolicy: PreflightRule = {
  id: 'no-restart-policy',
  run(ctx) {
    if (!ctx.model) return [];
    // `restart: "no"` is Compose's default and means "do not restart", which
    // `docker compose config` may render explicitly, so treat it as no policy.
    return ctx.model.services
      .filter(s => (!s.restart || s.restart === 'no') && !(s.deploy && s.deploy['restart_policy'] !== undefined))
      .map(s => ({
        ruleId: 'no-restart-policy',
        severity: 'warning' as const,
        title: 'No restart policy',
        message: `Service "${s.name}" has no restart policy, so it will not come back after a crash or host reboot.`,
        sourcePath: s.name,
        service: s.name,
        remediation: 'For long-running services, add restart: unless-stopped. For one-shot or init jobs that should finish and stay stopped, restart: "no" is appropriate.',
      }));
  },
};

const noHealthcheck: PreflightRule = {
  id: 'no-healthcheck',
  run(ctx) {
    if (!ctx.model) return [];
    return ctx.model.services
      .filter(s => ctx.healthchecks[s.name]?.state === 'absent')
      .map(s => {
        const origin = ctx.healthchecks[s.name]?.origin;
        let from = 'available evidence';
        if (origin === 'runtime') from = 'currently running containers';
        else if (origin === 'local-image') from = 'the locally available image';
        return {
          ruleId: 'no-healthcheck',
          severity: 'warning' as const,
          title: 'No effective healthcheck detected',
          message: `Service "${s.name}" has no effective healthcheck in ${from}, so Docker and Sencho cannot tell when it is actually ready.`,
          sourcePath: s.name,
          service: s.name,
          remediation: 'Add a healthcheck to the Compose service, or use an image that defines one.',
        };
      });
  },
};

const healthcheckDisabled: PreflightRule = {
  id: 'healthcheck-disabled',
  run(ctx) {
    if (!ctx.model) return [];
    return ctx.model.services
      .filter(s => ctx.healthchecks[s.name]?.state === 'explicitly-disabled')
      .map(s => ({
        ruleId: 'healthcheck-disabled',
        severity: 'warning' as const,
        title: 'Healthcheck explicitly disabled',
        message: `Service "${s.name}" disables its healthcheck in the Compose model (disable: true or test: NONE), so Docker will not report readiness for this service.`,
        sourcePath: s.name,
        service: s.name,
        remediation: 'Remove the disablement, or replace it with an active healthcheck if the service should report readiness.',
      }));
  },
};

const healthcheckInherited: PreflightRule = {
  id: 'healthcheck-inherited',
  run(ctx) {
    if (!ctx.model) return [];
    return ctx.model.services
      .filter(s => {
        const state = ctx.healthchecks[s.name]?.state;
        return state === 'runtime-inherited' || state === 'local-image-inherited';
      })
      .map(s => {
        const origin = ctx.healthchecks[s.name]?.origin;
        const from = origin === 'runtime'
          ? 'Docker is using the healthcheck from the currently running container image'
          : 'Docker is using the healthcheck defined by the locally available container image';
        return {
          ruleId: 'healthcheck-inherited',
          severity: 'info' as const,
          title: 'Healthcheck inherited from image',
          message: `Service "${s.name}" does not declare a healthcheck in Compose. ${from}.`,
          sourcePath: s.name,
          service: s.name,
          remediation: 'Optionally declare the healthcheck in Compose so its configuration remains explicit and independently controlled.',
        };
      });
  },
};

const healthcheckUnverifiable: PreflightRule = {
  id: 'healthcheck-unverifiable',
  run(ctx) {
    if (!ctx.model) return [];
    return ctx.model.services
      .filter(s => ctx.healthchecks[s.name]?.state === 'unverifiable')
      .map(s => ({
        ruleId: 'healthcheck-unverifiable',
        severity: 'info' as const,
        title: 'Healthcheck inheritance could not be verified',
        message: `Service "${s.name}" has no Compose healthcheck, and Sencho could not verify whether the running container or a local image provides one (Docker unreachable, image missing locally, or incomplete inspection).`,
        sourcePath: s.name,
        service: s.name,
        remediation: 'Ensure the declared image is present locally, or add an explicit Compose healthcheck. Sencho does not pull images during Doctor runs.',
      }));
  },
};

const healthcheckInconsistent: PreflightRule = {
  id: 'healthcheck-inconsistent',
  run(ctx) {
    if (!ctx.model) return [];
    return ctx.model.services
      .filter(s => ctx.healthchecks[s.name]?.state === 'inconsistent-replicas')
      .map(s => ({
        ruleId: 'healthcheck-inconsistent',
        severity: 'warning' as const,
        title: 'Replica healthcheck coverage is inconsistent',
        message: `Service "${s.name}" has running replicas with mixed effective healthcheck coverage, so readiness is not uniform across replicas.`,
        sourcePath: s.name,
        service: s.name,
        remediation: 'Recreate the service so every replica uses the same image and healthcheck configuration.',
      }));
  },
};

const SWARM_ONLY_DEPLOY_KEYS = ['placement', 'update_config', 'rollback_config', 'endpoint_mode'];
const deploySwarmOnly: PreflightRule = {
  id: 'deploy-swarm-only',
  run(ctx) {
    if (!ctx.model) return [];
    const findings: PreflightFinding[] = [];
    for (const s of ctx.model.services) {
      if (!s.deploy) continue;
      const present = SWARM_ONLY_DEPLOY_KEYS.filter(k => s.deploy?.[k] !== undefined);
      if (present.length === 0) continue;
      findings.push({
        ruleId: 'deploy-swarm-only',
        severity: 'warning',
        title: 'Swarm-only deploy fields',
        message: `Service "${s.name}" sets deploy.${present.join(', deploy.')}, which standalone Compose ignores (these apply to Swarm).`,
        sourcePath: s.name,
        service: s.name,
        remediation: 'Remove the Swarm-only deploy fields or move equivalent settings to their standalone keys.',
      });
    }
    return findings;
  },
};

const nodeStateUnavailable: PreflightRule = {
  id: 'node-state-unavailable',
  run(ctx) {
    // Emit only when the model rendered but the node's Docker snapshot could not be
    // read: an unrenderable model already raises its own render-failed blocker, so a
    // second advisory there would be noise. The rules that read node state suppress
    // themselves in this state; this finding tells the operator why, so a clean pass
    // during an outage is not mistaken for full coverage. (The info-only
    // new-network / new-volume notices are gated too, but left out of the message
    // below: they preview a deploy action rather than flag a problem.)
    if (!ctx.model || ctx.nodeStateAvailable) return [];
    return [{
      ruleId: 'node-state-unavailable',
      severity: 'info',
      title: 'Node-state checks skipped',
      message: 'The node\'s Docker state could not be read, so the external-resource, host-port, and container_name checks did not run. This result is partial.',
      remediation: 'Confirm the Docker daemon is reachable on this node, then re-run preflight.',
    }];
  },
};

const externalNetworkMissing: PreflightRule = {
  id: 'external-network-missing',
  run(ctx) {
    if (!ctx.model || !ctx.nodeStateAvailable) return [];
    return classifyMissingExternalNetworks(ctx.model, ctx.existingNetworkNames).map((group) => {
      const keysLabel = group.keys.map((k) => `networks.${k}`).join(', ');
      if (group.safe) {
        return {
          ruleId: 'external-network-missing',
          severity: 'blocker' as const,
          title: 'External network not found',
          message: `The model requires the external network "${group.name}" (Compose keys: ${group.keys.join(', ')}), which does not exist on this node. The deploy will fail.`,
          sourcePath: keysLabel,
          remediation: `Create it with: docker network create ${group.name}`,
        };
      }
      const reasons = [
        group.blockReason === 'unsupported_driver'
          ? `unsupported driver kind(s): ${[...new Set(group.declarations.map((d) => d.driverKind))].join(', ')}`
          : null,
        group.unsupportedFeatures.length > 0
          ? `unsupported options: ${group.unsupportedFeatures.join(', ')}`
          : null,
        group.blockReason === 'invalid_name' ? 'invalid Docker network name' : null,
        group.blockReason === 'reserved_system' ? 'reserved system network name' : null,
      ].filter(Boolean).join('; ');
      return {
        ruleId: 'external-network-missing',
        severity: 'blocker' as const,
        title: 'External network not found',
        message: `The model requires the external network "${group.name}" (Compose keys: ${group.keys.join(', ')}), which does not exist on this node. Sencho cannot create it automatically (${reasons}).`,
        sourcePath: keysLabel,
        remediation: 'Create this network outside Sencho with the required driver and options, or simplify the Compose declaration to a plain external bridge network.',
      };
    });
  },
};

const externalVolumeMissing: PreflightRule = {
  id: 'external-volume-missing',
  run(ctx) {
    if (!ctx.model || !ctx.nodeStateAvailable) return [];
    const findings: PreflightFinding[] = [];
    for (const [key, vol] of Object.entries(ctx.model.volumes)) {
      if (!vol.external || ctx.existingVolumeNames.has(vol.name)) continue;
      findings.push({
        ruleId: 'external-volume-missing',
        severity: 'blocker',
        title: 'External volume not found',
        message: `The model requires the external volume "${vol.name}", which does not exist on this node. The deploy will fail.`,
        sourcePath: `volumes.${key}`,
        remediation: `Create it with: docker volume create ${vol.name}`,
      });
    }
    return findings;
  },
};

const newNetwork: PreflightRule = {
  id: 'new-network',
  run(ctx) {
    if (!ctx.model || !ctx.nodeStateAvailable) return [];
    const findings: PreflightFinding[] = [];
    for (const [key, net] of Object.entries(ctx.model.networks)) {
      if (net.external || key === 'default') continue;
      const expected = runtimeResourceName(ctx.model.projectName, key, net.name);
      if (ctx.existingNetworkNames.has(expected)) continue;
      findings.push({
        ruleId: 'new-network',
        severity: 'info',
        title: 'New network will be created',
        message: `Deploying will create the network "${expected}".`,
        sourcePath: `networks.${key}`,
      });
    }
    return findings;
  },
};

const newVolume: PreflightRule = {
  id: 'new-volume',
  run(ctx) {
    if (!ctx.model || !ctx.nodeStateAvailable) return [];
    const findings: PreflightFinding[] = [];
    for (const [key, vol] of Object.entries(ctx.model.volumes)) {
      if (vol.external) continue;
      const expected = runtimeResourceName(ctx.model.projectName, key, vol.name);
      if (ctx.existingVolumeNames.has(expected)) continue;
      findings.push({
        ruleId: 'new-volume',
        severity: 'info',
        title: 'New volume will be created',
        message: `Deploying will create the named volume "${expected}".`,
        sourcePath: `volumes.${key}`,
      });
    }
    return findings;
  },
};

const anonymousVolume: PreflightRule = {
  id: 'anonymous-volume',
  run(ctx) {
    if (!ctx.model) return [];
    const findings: PreflightFinding[] = [];
    for (const svc of ctx.model.services) {
      const anon = (svc.storageMounts ?? []).filter(m => m.type === 'anonymous');
      if (anon.length === 0) continue;
      const targets = anon.map(m => m.target).filter(Boolean);
      findings.push({
        ruleId: 'anonymous-volume',
        severity: 'info',
        title: 'Anonymous volume in use',
        message: `Service "${svc.name}" mounts ${anon.length > 1 ? `${anon.length} anonymous volumes` : 'an anonymous volume'}${targets.length ? ` at ${targets.join(', ')}` : ''}. Anonymous volumes have no name, so they are easy to miss when backing up and are orphaned when the container is recreated.`,
        sourcePath: svc.name,
        service: svc.name,
        remediation: 'Give the volume a name so it can be referenced, backed up, and reattached.',
      });
    }
    return findings;
  },
};

const containerNameInternalDup: PreflightRule = {
  id: 'container-name-internal-dup',
  run(ctx) {
    if (!ctx.model) return [];
    const byName = new Map<string, string[]>();
    for (const s of ctx.model.services) {
      if (!s.containerName) continue;
      const list = byName.get(s.containerName);
      if (list) list.push(s.name); else byName.set(s.containerName, [s.name]);
    }
    const findings: PreflightFinding[] = [];
    for (const [name, services] of byName) {
      if (services.length < 2) continue;
      findings.push({
        ruleId: 'container-name-internal-dup',
        severity: 'blocker',
        title: 'Duplicate container_name',
        message: `Services ${services.map(s => `"${s}"`).join(' and ')} both set container_name "${name}". Docker requires unique names, so the deploy will fail.`,
        remediation: 'Give each service a unique container_name, or remove it and let Compose name them.',
      });
    }
    return findings;
  },
};

const containerNameCollision: PreflightRule = {
  id: 'container-name-collision',
  run(ctx) {
    if (!ctx.model || !ctx.nodeStateAvailable) return [];
    const findings: PreflightFinding[] = [];
    for (const s of ctx.model.services) {
      if (!s.containerName) continue;
      const clash = ctx.existingContainers.find(c => c.name === s.containerName && c.stack !== ctx.stackName);
      if (!clash) continue;
      const owner = clash.stack ? `stack "${clash.stack}"` : 'an unmanaged container';
      findings.push({
        ruleId: 'container-name-collision',
        severity: 'blocker',
        title: 'container_name already in use',
        message: `container_name "${s.containerName}" for service "${s.name}" is already used by ${owner} on this node. The deploy will fail with a name conflict.`,
        sourcePath: s.name,
        service: s.name,
        remediation: 'Choose a different container_name or remove the conflicting container.',
      });
    }
    return findings;
  },
};

const effectiveModelExpanded: PreflightRule = {
  id: 'effective-model-expanded',
  run(ctx) {
    // Skip when the source could not be read: an empty source-service set then
    // means "unknown", not "zero services", and would flag every service.
    if (!ctx.model || !ctx.sourceReadable) return [];
    const source = new Set(ctx.sourceServiceNames);
    const extra = ctx.model.services.map(s => s.name).filter(n => !source.has(n));
    if (extra.length === 0) return [];
    return [{
      ruleId: 'effective-model-expanded',
      severity: 'info',
      title: 'Effective model adds services',
      message: `The effective model includes ${extra.map(s => `"${s}"`).join(', ')}, which are not in this file (pulled in via include, extends, or profiles). What deploys differs from what you see here.`,
      remediation: 'Review the included files to confirm this is intended.',
    }];
  },
};

const selfManagedStack: PreflightRule = {
  id: 'self-managed-stack',
  run(ctx) {
    if (!ctx.isSelfStack) return [];
    return [{
      ruleId: 'self-managed-stack',
      severity: 'warning',
      title: 'This stack is the running Sencho instance',
      message: 'Sencho discovered its own compose project as a managed stack. Generic deploy, update, stop, down, and delete actions are blocked here because they would recreate or remove the dashboard you are using.',
      remediation: 'Update Sencho via Fleet -> Node Update. To manage it as a normal stack, move its compose project outside COMPOSE_DIR.',
    }];
  },
};

// ----- exposure-intent rules ------------------------------------------------
// These read the user's stored exposure classification (resolved per service)
// and the dossier's documented access URLs from the context, plus a sensitivity
// heuristic on the image name for the broad-exposure rule.

/** Image-name hints for a database or admin service that should rarely be broadly exposed. */
const SENSITIVE_IMAGE_HINTS = [
  'postgres', 'mysql', 'mariadb', 'mongo', 'redis', 'memcached', 'elasticsearch',
  'adminer', 'phpmyadmin', 'portainer', 'docker-socket-proxy',
];
/** Reverse-proxy label-key base names; matched as the key itself or a `base.` prefix (case-insensitive). */
const REVERSE_PROXY_LABEL_HINTS = ['traefik', 'caddy', 'virtual.host'];

/** A service's effective intent: its own override, else the stack-level intent. */
function effectiveIntent(ctx: PreflightContext, service: string): ExposureIntent | null {
  return ctx.serviceIntents[service] ?? ctx.stackIntent;
}

const exposureInternalPublished: PreflightRule = {
  id: 'exposure-internal-published',
  run(ctx) {
    if (!ctx.model) return [];
    const findings: PreflightFinding[] = [];
    for (const svc of ctx.model.services) {
      const intent = effectiveIntent(ctx, svc.name);
      if (intent !== 'internal' && intent !== 'same-node') continue;
      // same-node tolerates a loopback binding; internal tolerates no host port.
      const offending = svc.ports.filter(p => intent === 'internal' || !isLoopback(p.hostIp));
      if (offending.length === 0) continue;
      findings.push({
        ruleId: 'exposure-internal-published',
        severity: 'high',
        title: `"${svc.name}" is classified ${intent} but publishes a host port`,
        message: `Service "${svc.name}" is classified as ${intent} exposure, but it publishes ${offending.map(specLabel).join(', ')} to the host, which contradicts that intent.`,
        sourcePath: svc.name,
        service: svc.name,
        remediation: intent === 'same-node'
          ? 'Bind the port to loopback (127.0.0.1), remove it, or reclassify the exposure intent.'
          : 'Remove the published port or reclassify the exposure intent.',
      });
    }
    return findings;
  },
};

const exposureUnclassified: PreflightRule = {
  id: 'exposure-unclassified',
  run(ctx) {
    if (!ctx.model) return [];
    // A read failure leaves every intent null; do not read that as "unclassified".
    if (!ctx.exposureAvailable) return [];
    const publishing = ctx.model.services.filter(s => s.ports.length > 0);
    if (publishing.length === 0) return [];
    // Fire only when a publishing service is still effectively unclassified: a
    // service-level intent suppresses the warning for that service even when the
    // stack itself is unset.
    const unclassified = publishing.some(s => {
      const intent = effectiveIntent(ctx, s.name);
      return intent === null || intent === 'unknown';
    });
    if (!unclassified) return [];
    return [{
      ruleId: 'exposure-unclassified',
      severity: 'warning',
      title: 'Stack publishes ports without an exposure intent',
      message: 'This stack publishes one or more host ports but has no exposure intent set. Classifying it (internal, LAN, reverse proxy, public) lets Sencho flag mismatches later.',
      remediation: 'Set the stack exposure intent in the Networking tab.',
    }];
  },
};

const exposurePortVsDossier: PreflightRule = {
  id: 'exposure-port-vs-dossier',
  run(ctx) {
    if (!ctx.model || !ctx.hasAccessUrls) return [];
    const findings: PreflightFinding[] = [];
    for (const svc of ctx.model.services) {
      const undocumented = [...new Set(svc.ports.flatMap(portsOf).filter(p => !ctx.accessUrlPorts.has(p)))];
      if (undocumented.length === 0) continue;
      findings.push({
        ruleId: 'exposure-port-vs-dossier',
        severity: 'warning',
        title: 'Published port is not in the documented access URLs',
        message: `Service "${svc.name}" publishes ${undocumented.join(', ')}, which ${undocumented.length > 1 ? 'are' : 'is'} not referenced by the dossier's documented access URLs. The documentation may be stale.`,
        sourcePath: svc.name,
        service: svc.name,
        remediation: 'Update the access URLs in the Stack Dossier, or change the published port.',
      });
    }
    return findings;
  },
};

const reverseProxyUndocumented: PreflightRule = {
  id: 'reverse-proxy-undocumented',
  run(ctx) {
    if (!ctx.model) return [];
    // A read failure hides both the intent and any documented access URLs; do not
    // interpret that absence as an undocumented reverse proxy.
    if (!ctx.exposureAvailable) return [];
    // Already documented or intentionally reverse-proxied at the stack level.
    if (ctx.hasAccessUrls || ctx.stackIntent === 'reverse-proxy') return [];
    const findings: PreflightFinding[] = [];
    for (const svc of ctx.model.services) {
      if (effectiveIntent(ctx, svc.name) === 'reverse-proxy') continue;
      const hasRpLabel = svc.labelKeys.some(k => {
        const lk = k.toLowerCase();
        return REVERSE_PROXY_LABEL_HINTS.some(h => lk === h || lk.startsWith(`${h}.`));
      });
      if (!hasRpLabel) continue;
      findings.push({
        ruleId: 'reverse-proxy-undocumented',
        severity: 'warning',
        title: `"${svc.name}" has reverse-proxy labels but no documented URL`,
        message: `Service "${svc.name}" carries reverse-proxy labels, but the stack has no documented access URL or reverse-proxy intent, so how it is reached is unclear.`,
        sourcePath: svc.name,
        service: svc.name,
        remediation: 'Document the access URL in the Stack Dossier or set the exposure intent to reverse proxy.',
      });
    }
    return findings;
  },
};

const sensitiveServiceBroadExposure: PreflightRule = {
  id: 'sensitive-service-broad-exposure',
  run(ctx) {
    if (!ctx.model) return [];
    const findings: PreflightFinding[] = [];
    for (const svc of ctx.model.services) {
      if (svc.image === undefined) continue;
      const image = svc.image.toLowerCase();
      if (!SENSITIVE_IMAGE_HINTS.some(h => image.includes(h))) continue;
      const broad = svc.ports.filter(p => isAllInterfaces(p.hostIp));
      if (broad.length === 0) continue;
      findings.push({
        ruleId: 'sensitive-service-broad-exposure',
        severity: 'high',
        title: `Sensitive service "${svc.name}" is exposed on all interfaces`,
        message: `Service "${svc.name}" looks like a database or admin service (${svc.image}) and publishes ${broad.map(specLabel).join(', ')} on all interfaces. Broadly exposing it is a common source of compromise.`,
        sourcePath: svc.name,
        service: svc.name,
        remediation: 'Bind it to a specific interface such as 127.0.0.1, or keep it on an internal network only.',
      });
    }
    return findings;
  },
};

/** The ordered registry. Order is the display order within a severity group. */
export const PREFLIGHT_RULES: PreflightRule[] = [
  renderFailed,
  envUnset,
  envLiteralDollar,
  envFileMissing,
  portConflictNode,
  portConflictInternal,
  portExposedAllInterfaces,
  bindPathMissing,
  bindPathPermission,
  dockerSocketMount,
  dockerSocketProxy,
  dockerSocketProxyWritable,
  dockerSocketProxyPublished,
  dockerSocketProxyMutating,
  dockerSocketProxyExposure,
  dockerSocketProxyClient,
  privileged,
  networkModeHost,
  uidGidRisk,
  imageLatest,
  noRestartPolicy,
  noHealthcheck,
  healthcheckDisabled,
  healthcheckInherited,
  healthcheckUnverifiable,
  healthcheckInconsistent,
  deploySwarmOnly,
  nodeStateUnavailable,
  externalNetworkMissing,
  externalVolumeMissing,
  newNetwork,
  newVolume,
  anonymousVolume,
  containerNameInternalDup,
  containerNameCollision,
  exposureInternalPublished,
  sensitiveServiceBroadExposure,
  exposureUnclassified,
  exposurePortVsDossier,
  reverseProxyUndocumented,
  effectiveModelExpanded,
  selfManagedStack,
];

export const RULE_IDS: readonly string[] = PREFLIGHT_RULES.map(r => r.id);

/** Run every rule and concatenate findings. */
export function runRules(ctx: PreflightContext): PreflightFinding[] {
  return PREFLIGHT_RULES.flatMap(rule => rule.run(ctx));
}
