import fs from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';

import DockerController from './DockerController';
import { ComposeService } from './ComposeService';
import { FileSystemService } from './FileSystemService';
import { DatabaseService } from './DatabaseService';
import { computeStackHashes } from './DriftLedgerService';
import { parseComposeDependencies } from '../helpers/composeDependencyParse';
import { parseEffectiveModel, type EffectiveModel } from './preflight/effectiveModel';
import { getExposureContext } from './network/exposureContext';
import type { ExposureIntent } from './network/types';
import { runRules, SEVERITY_RANK, RULE_IDS, RENDER_FAILED_RULE_ID, isPreflightNoteFinding } from './preflight/rules';
import type {
  BindCheck, NodePortBinding, PreflightContext, PreflightFinding, PreflightReport, PreflightSeverity, PreflightStatus, MissingEnvFile,
  ServiceHealthcheckEvidence,
} from './preflight/types';
import { collectServiceHealthcheckEvidence } from './healthcheck/collectServiceHealthcheckEvidence';
import { applyPreflightAcknowledgements, parseServiceImages } from '../utils/preflight-ack-filter';

import { isPathWithinBase } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
import { parseUnsetEnvVars, parseMissingRequiredVars, readEnvFileKeys } from '../helpers/envVarParse';
import { resolveStackEnvSources } from '../helpers/envFileResolution';
import { isSelfStack } from '../helpers/selfStackGuard';
import { classifyUnsetEnvVars, type LiteralDollarWarning } from '../helpers/unsetEnvClassification';

const MAX_RENDER_ERROR = 600; // chars kept from a (redacted) render error

const ruleOrder = new Map(RULE_IDS.map((id, i) => [id, i]));
/** Severity descending, then registry order, so output is deterministic. */
function sortFindings(findings: PreflightFinding[]): PreflightFinding[] {
  return [...findings].sort((a, b) =>
    (SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]) ||
    ((ruleOrder.get(a.ruleId) ?? 0) - (ruleOrder.get(b.ruleId) ?? 0)));
}

function highestOf(findings: PreflightFinding[]): PreflightSeverity | null {
  let best: PreflightSeverity | null = null;
  for (const f of findings) {
    if (isPreflightNoteFinding(f.ruleId)) continue;
    if (best === null || SEVERITY_RANK[f.severity] > SEVERITY_RANK[best]) best = f.severity;
  }
  return best;
}

function activeFields(
  renderable: boolean,
  findings: PreflightFinding[],
): Pick<PreflightReport, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'> {
  // Notes stay in `findings` for display but do not affect All Clear or active severity.
  const issueFindings = findings.filter(f => !isPreflightNoteFinding(f.ruleId));
  const active = issueFindings.filter(f => !f.acknowledged);
  const acknowledgedCount = issueFindings.length - active.length;
  const activeHighestSeverity = highestOf(active);
  const activeStatus: PreflightStatus = !renderable
    ? 'unrenderable'
    : (activeHighestSeverity ?? 'pass');
  return {
    activeStatus,
    activeHighestSeverity,
    activeCount: active.length,
    acknowledgedCount,
  };
}

function buildServiceImages(model: EffectiveModel | null): string | null {
  if (!model) return null;
  const map: Record<string, string> = {};
  for (const svc of model.services) {
    if (svc.image) map[svc.name] = svc.image;
  }
  return Object.keys(map).length > 0 ? JSON.stringify(map) : null;
}

function enrichReport(
  nodeId: number,
  stackName: string,
  report: Omit<PreflightReport, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'>,
): PreflightReport {
  const db = DatabaseService.getInstance();
  const acks = db.getPreflightAcknowledgements(nodeId, stackName);
  const serviceImages = parseServiceImages(
    db.getLatestPreflightRun(nodeId, stackName)?.service_images ?? null,
  );
  const findings = applyPreflightAcknowledgements(
    report.findings,
    { renderedHash: report.renderedHash, serviceImages },
    acks,
  );
  return {
    ...report,
    findings,
    ...activeFields(report.renderable, findings),
  };
}

/**
 * Compose Doctor: renders the effective model and runs the deterministic
 * preflight rule registry against the active node. Advisory only (it never
 * blocks a deploy), read-only with respect to the stack, and node-scoped. It
 * never stores, returns, or logs an environment value.
 */
export class ComposeDoctorService {
  private static instance: ComposeDoctorService | null = null;

  static getInstance(): ComposeDoctorService {
    if (!ComposeDoctorService.instance) ComposeDoctorService.instance = new ComposeDoctorService();
    return ComposeDoctorService.instance;
  }

  private constructor() { /* singleton */ }

  /** Run all checks, persist the result (replacing any prior run), return the report. */
  async runPreflight(nodeId: number, stackName: string, ranBy: string | null): Promise<PreflightReport> {
    const fsSvc = FileSystemService.getInstance(nodeId);
    let source: string | null = null;
    try {
      source = await fsSvc.getStackContent(stackName);
    } catch (err) {
      // An unreadable source is logged here (not silently swallowed) so a later
      // skipped hash or source comparison is traceable.
      console.warn('[ComposeDoctor] Source unreadable for %s; source-derived checks skipped:',
        sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown')));
    }
    const sourceReadable = source !== null;
    // renderedHash is the parsed-SOURCE-model hash (the same `rendered_hash`
    // meaning the drift ledger uses), deliberately not a hash of docker's
    // rendered output, which inlines resolved env values and must never be hashed.
    const hashes = source !== null
      ? computeStackHashes(source)
      : { sourceHash: null as string | null, renderedHash: null as string | null };
    const sourceServiceNames = source !== null ? parseComposeDependencies(source).services.map(s => s.name) : [];

    const ctx = await this.buildContext(nodeId, stackName, sourceServiceNames, sourceReadable);
    const findings = sortFindings(runRules(ctx));
    const highestSeverity = highestOf(findings);
    const status: PreflightStatus = !ctx.renderable ? 'unrenderable' : (highestSeverity ?? 'pass');
    const serviceImages = buildServiceImages(ctx.model);

    const report: PreflightReport = {
      stack: stackName,
      ranAt: Date.now(),
      ranBy,
      renderable: ctx.renderable,
      renderError: ctx.renderError,
      status,
      highestSeverity,
      sourceHash: hashes.sourceHash,
      renderedHash: hashes.renderedHash,
      findings,
      activeStatus: status,
      activeHighestSeverity: highestSeverity,
      activeCount: findings.length,
      acknowledgedCount: 0,
    };
    this.persist(nodeId, report, serviceImages);
    return enrichReport(nodeId, stackName, report);
  }

  /** Read the last stored run for a stack, mapped to the report shape. */
  getLatest(nodeId: number, stackName: string): PreflightReport {
    const db = DatabaseService.getInstance();
    const run = db.getLatestPreflightRun(nodeId, stackName);
    if (!run) {
      return {
        stack: stackName, ranAt: null, ranBy: null, renderable: true, renderError: null,
        status: 'never-run', highestSeverity: null, sourceHash: null, renderedHash: null, findings: [],
        activeStatus: 'never-run', activeHighestSeverity: null, activeCount: 0, acknowledgedCount: 0,
      };
    }
    const findings = sortFindings(db.getPreflightFindings(run.id).map(r => ({
      ruleId: r.rule_id,
      severity: r.severity as PreflightSeverity,
      title: r.title,
      message: r.message,
      sourcePath: r.source_path ?? undefined,
      remediation: r.remediation ?? undefined,
      service: r.service ?? undefined,
    })));
    const renderable = run.status !== 'unrenderable';
    // The render error is carried by the render-failed finding, not a column.
    const renderError = renderable ? null : (findings.find(f => f.ruleId === RENDER_FAILED_RULE_ID)?.message ?? null);
    const base: Omit<PreflightReport, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'> = {
      stack: stackName,
      ranAt: run.created_at,
      ranBy: run.created_by,
      renderable,
      renderError,
      status: run.status as PreflightStatus,
      highestSeverity: (run.highest_severity as PreflightSeverity | null) ?? null,
      sourceHash: run.source_hash,
      renderedHash: run.rendered_hash,
      findings,
    };
    return enrichReport(nodeId, stackName, base);
  }

  private async buildContext(nodeId: number, stackName: string, sourceServiceNames: string[], sourceReadable: boolean): Promise<PreflightContext> {
    const fsSvc = FileSystemService.getInstance(nodeId);
    const baseDir = fsSvc.getBaseDir();

    let renderable = false;
    let renderError: string | null = null;
    let model: EffectiveModel | null = null;
    let unsetEnvVars: string[] = [];
    let literalDollarWarnings: LiteralDollarWarning[] = [];
    let missingEnvFiles: MissingEnvFile[] = [];

    let envSources: Awaited<ReturnType<typeof resolveStackEnvSources>> | null = null;
    try {
      envSources = await resolveStackEnvSources(nodeId, stackName);
      missingEnvFiles = envSources.envFiles
        .filter(f => f.isInjectionSource && f.required && f.existence === 'missing')
        .map(f => ({ rawPath: f.rawPaths[0], services: f.declaringServices }));
    } catch (err) {
      console.warn('[ComposeDoctor] env-file resolution failed for %s:',
        sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown')));
    }

    try {
      const result = await ComposeService.getInstance(nodeId).renderConfig(stackName);
      if (result.rendered !== null) {
        // Unset-variable warnings come from stderr and do not depend on the
        // model parsing, so capture them before attempting the parse, so a parse
        // failure does not also suppress the env-unset findings.
        const rawUnset = parseUnsetEnvVars(result.stderr);
        if (envSources) {
          const envFileKeys: string[] = [];
          for (const file of envSources.envFiles) {
            if (!file.resolvedPath || file.existence !== 'present') continue;
            const { keys, unverifiable } = await readEnvFileKeys(file.resolvedPath, baseDir);
            if (!unverifiable) envFileKeys.push(...keys);
          }
          const classified = classifyUnsetEnvVars(rawUnset, envSources, envFileKeys);
          unsetEnvVars = classified.intentional;
          literalDollarWarnings = classified.literalDollar;
        } else {
          // Without authored env context, never surface raw stderr names as unset
          // variables; they may be literal-dollar fragments from secret values.
          unsetEnvVars = [];
          literalDollarWarnings = rawUnset.length > 0 ? [{ likelySecret: false }] : [];
        }
        try {
          model = parseEffectiveModel(JSON.parse(result.rendered), stackName);
          renderable = true;
        } catch (parseErr) {
          // JSON.parse errors carry no file content, so the message is safe to log.
          console.warn('[ComposeDoctor] Effective model parse failed for %s:',
            sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(parseErr, 'unknown')));
          renderError = 'Sencho could not parse the rendered Compose model.';
        }
      } else {
        // The raw stderr from `docker compose config` can echo file content
        // (and therefore secrets), so it is never stored. We surface only safe,
        // structural signals: the names of any required variables Compose
        // reported as missing, otherwise a generic nudge.
        const missing = parseMissingRequiredVars(result.stderr);
        renderError = missing.length
          ? `Required variable${missing.length > 1 ? 's' : ''} ${missing.join(', ')} ${missing.length > 1 ? 'have' : 'has'} no value, so the effective model cannot be rendered.`
          : 'Sencho could not render the effective Compose model. Check the compose and env files for a YAML syntax error, an unresolved include or merge, or a required variable with no value, then re-run.';
      }
    } catch (err) {
      // Spawn failure (docker unavailable). Spawn errors carry no file content;
      // redact defensively anyway.
      renderError = redactSensitiveText(getErrorMessage(err, 'docker compose could not be started.')).slice(0, MAX_RENDER_ERROR).trim()
        || 'Sencho could not run docker compose on this node.';
    }

    const { nodePorts, existingNetworkNames, existingVolumeNames, existingContainers, nodeStateAvailable } = await this.nodeState(nodeId, fsSvc, stackName);
    const [bindChecks, healthchecks] = await Promise.all([
      model ? this.resolveBindChecks(model, baseDir) : Promise.resolve([] as BindCheck[]),
      model
        ? collectServiceHealthcheckEvidence(nodeId, stackName, model, nodeStateAvailable)
        : Promise.resolve({} as Record<string, ServiceHealthcheckEvidence>),
    ]);
    const { stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls, exposureAvailable } = this.exposureState(nodeId, stackName);
    const selfStack = await isSelfStack(stackName);

    return {
      stackName,
      platform: process.platform,
      model,
      renderable,
      renderError,
      unsetEnvVars,
      literalDollarWarnings,
      missingEnvFiles,
      sourceServiceNames,
      sourceReadable,
      nodePorts,
      existingNetworkNames,
      existingVolumeNames,
      existingContainers,
      nodeStateAvailable,
      bindChecks,
      stackIntent,
      serviceIntents,
      accessUrlPorts,
      hasAccessUrls,
      exposureAvailable,
      isSelfStack: selfStack,
      healthchecks,
    };
  }

  /**
   * The user's stored exposure intent (resolved into stack-level + per-service)
   * and the dossier's documented access-URL ports, for the exposure rules.
   * Delegates to the shared exposure-context helper (also used by the live
   * Networking findings engine) so both engines can never diverge on severity.
   * Fail-soft: a read error defaults to unset/empty so the rules simply do not
   * fire rather than the whole preflight failing.
   */
  private exposureState(nodeId: number, stackName: string): {
    stackIntent: ExposureIntent | null;
    serviceIntents: Record<string, ExposureIntent>;
    accessUrlPorts: Set<number>;
    hasAccessUrls: boolean;
    exposureAvailable: boolean;
  } {
    const context = getExposureContext(nodeId, stackName);
    if (!context.available) {
      return { stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false, exposureAvailable: false };
    }
    return {
      stackIntent: context.stackIntent,
      serviceIntents: context.serviceIntents,
      accessUrlPorts: context.accessUrlPorts,
      hasAccessUrls: context.hasAccessUrls,
      exposureAvailable: true,
    };
  }

  /** Snapshot the node's ports/networks/volumes/containers. Degrades to empty if Docker is unreachable. */
  private async nodeState(nodeId: number, fsSvc: FileSystemService, stackName: string): Promise<{
    nodePorts: NodePortBinding[];
    existingNetworkNames: Set<string>;
    existingVolumeNames: Set<string>;
    existingContainers: { name: string; stack: string | null }[];
    nodeStateAvailable: boolean;
  }> {
    try {
      const knownStacks = await fsSvc.getStacks();
      const snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(knownStacks);
      const nodePorts = snapshot.containers.flatMap(c =>
        c.ports.map(p => ({ publishedPort: p.publishedPort, protocol: p.protocol, ip: p.ip, stack: c.stack })));
      return {
        nodePorts,
        existingNetworkNames: new Set(snapshot.networks.map(n => n.name)),
        existingVolumeNames: new Set(snapshot.volumes.map(v => v.name)),
        existingContainers: snapshot.containers.map(c => ({ name: c.name, stack: c.stack })),
        nodeStateAvailable: true,
      };
    } catch (error) {
      console.warn('[ComposeDoctor] Node snapshot unavailable for %s; node-state checks skipped:',
        sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
      return { nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(), existingContainers: [], nodeStateAvailable: false };
    }
  }

  /**
   * Stat each bind-mount source. Existence/ownership is probed ONLY for sources
   * that resolve inside the node's compose base dir (relative binds); absolute
   * host paths are outside Sencho's filesystem view and are left unverified.
   */
  private async resolveBindChecks(model: EffectiveModel, baseDir: string): Promise<BindCheck[]> {
    const resolvedBase = path.resolve(baseDir);
    const checks: BindCheck[] = [];
    for (const svc of model.services) {
      for (const bind of svc.binds) {
        const withinBase = isPathWithinBase(path.resolve(bind.source), resolvedBase);
        let exists = false;
        let ownerUid: number | null = null;
        if (withinBase) {
          try {
            const st = await fs.promises.stat(bind.source);
            exists = true;
            ownerUid = typeof st.uid === 'number' ? st.uid : null;
          } catch {
            exists = false;
          }
        }
        checks.push({ service: svc.name, source: bind.source, target: bind.target, withinBase, exists, ownerUid });
      }
    }
    return checks;
  }

  /** Persist the run, replacing any prior run for this stack. Best-effort. */
  private persist(nodeId: number, report: PreflightReport, serviceImages: string | null): void {
    if (report.ranAt === null) return;
    try {
      const runId = randomUUID();
      DatabaseService.getInstance().replacePreflightRun(
        {
          id: runId,
          node_id: nodeId,
          stack_name: report.stack,
          source_hash: report.sourceHash,
          rendered_hash: report.renderedHash,
          service_images: serviceImages,
          status: report.status,
          highest_severity: report.highestSeverity,
          created_at: report.ranAt,
          created_by: report.ranBy,
        },
        report.findings.map(f => ({
          id: randomUUID(),
          run_id: runId,
          rule_id: f.ruleId,
          severity: f.severity,
          title: f.title,
          message: f.message,
          source_path: f.sourcePath ?? null,
          remediation: f.remediation ?? null,
          service: f.service ?? null,
          created_at: report.ranAt!,
        })),
      );
    } catch (error) {
      console.error('[ComposeDoctor] Failed to persist preflight run for %s:',
        sanitizeForLog(report.stack), sanitizeForLog(getErrorMessage(error, 'unknown')));
    }
  }
}
