/**
 * Read-only aggregate for one generated or existing SDK project.
 *
 * @module @deepseek-ai/dsh-helper/project/sdk-project
 */

import { access, readFile } from 'node:fs/promises'
import { basename, resolve } from 'node:path'
import { CordisYamlFile, type CordisConfigEntry } from '../documents/cordis-yaml-file.ts'
import { EnvFile } from '../documents/env-file.ts'
import { PackageJsonFile, type PackageManifest } from '../documents/package-json-file.ts'
import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts'
import { ProjectFile, TextProjectFile } from '../documents/project-file.ts'
import { TsConfigFile } from '../documents/tsconfig-file.ts'
import {
  createPackageManager,
  type PackageManager,
  type PackageManagerName,
} from '../package-managers/package-manager.ts'
import {
  createBaselineProjectArtifacts,
  createPackageJsonDoc,
  createProjectTemplateContext,
} from '../templates/project-template.ts'
import type { ProjectCreationRequest, ProjectProfile, RunInterface } from './types.ts'
import type { FeatureRegistry } from '../features/registry.ts'
import { ProjectEditSession } from './project-edit-session.ts'

/** Whether a project snapshot describes uncommitted creation or files on disk. */
export type ProjectOrigin = 'create' | 'disk'

const OPTIONAL_DOCUMENTS = [
  '.env',
  '.env.example',
  'tsconfig.json',
  'pnpm-workspace.yaml',
  'hooks.json',
  'codex-hooks.json',
  'README.md',
  'index.ts',
] as const

function runInterface(entries: readonly CordisConfigEntry[]): RunInterface {
  if (entries.some(entry => entry.name === '@deepseek-ai/dsh-acp')) return 'acp'
  if (entries.some(entry => entry.name === '@deepseek-ai/dsh-tui')) return 'tui'
  return 'embed'
}

function runtimeModel(entries: readonly CordisConfigEntry[]): string {
  const acp = entries.find(entry => entry.name === '@deepseek-ai/dsh-acp')
  if (typeof acp?.config?.model === 'string' && acp.config.model.length > 0) return acp.config.model
  const provider = entries.find(entry => entry.name === '@deepseek-ai/dsh-llm-deepseek'
    || entry.name === '@deepseek-ai/dsh-llm-pi-ai')
  const models = provider?.config?.models
  if (Array.isArray(models) && typeof models[0] === 'string') return models[0]
  return 'deepseek-v4-flash'
}

function releaseVersion(manifest: Readonly<PackageManifest>): string {
  const spec = manifest.dependencies?.['@deepseek-ai/dsh-scripts']
  const match = spec && /(?:^|[^0-9])(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(spec)
  return match?.[1] ?? '0.0.1'
}

async function pathExists(path: string): Promise<boolean> {
  try {
    await access(path)
    return true
  } catch (error) {
    /* v8 ignore else -- the other arm requires a filesystem permission/IO fault from access */
    if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
    /* v8 ignore next -- paired with the ignored defensive access-error arm above */
    throw error
  }
}

async function detectPackageManager(root: string, manifest: Readonly<PackageManifest>): Promise<PackageManager> {
  let name: PackageManagerName = 'npm'
  let version = '10.0.0'
  const field = manifest.packageManager
  if (field) {
    const match = /^(npm|pnpm|yarn)@(.+)$/.exec(field)
    if (!match?.[1] || !match[2]) throw new Error(`invalid packageManager field: ${field}`)
    name = match[1] as PackageManagerName
    version = match[2]
  } else if (await pathExists(resolve(root, 'pnpm-lock.yaml'))) {
    name = 'pnpm'
    version = '10.0.0'
  } else if (await pathExists(resolve(root, 'yarn.lock'))) {
    name = 'yarn'
    version = '2.0.0'
  }
  return createPackageManager(name, version)
}

function linkedRepositoryRoot(root: string, manifest: Readonly<PackageManifest>): string | undefined {
  const spec = manifest.dependencies?.['@deepseek-ai/dsh-scripts']
  const match = /^(?:file|link|portal):(.+)\/packages\/sdk\/scripts\/?$/.exec(spec ?? '')
  return match?.[1] ? resolve(root, match[1]) : undefined
}

function parseOptionalDocument(path: string, text: string): ProjectFile {
  try {
    switch (path) {
      case '.env': return EnvFile.parse('.env', text)
      case '.env.example': return EnvFile.parse('.env.example', text)
      case 'tsconfig.json': return TsConfigFile.parse(text)
      case 'pnpm-workspace.yaml': return PnpmWorkspaceFile.parse(text)
      default: return new TextProjectFile(path, text, text)
    }
  } catch {
    // Optional malformed resources do not invalidate the project aggregate;
    // an operation that needs their structure checks the concrete document type.
    return new TextProjectFile(path, text, text)
  }
}

/** A project snapshot whose documents can only be changed through {@link ProjectEditSession}. */
export class SdkProject {
  /** Absolute project directory. */
  readonly root: string
  /** Whether this snapshot is an uncommitted blueprint or disk state. */
  readonly origin: ProjectOrigin
  /** Project identity, runtime, interface, and package-manager context. */
  readonly profile: ProjectProfile
  private readonly documents: ReadonlyMap<string, ProjectFile>

  private constructor(
    root: string,
    origin: ProjectOrigin,
    profile: ProjectProfile,
    documents: ReadonlyMap<string, ProjectFile>,
  ) {
    this.root = resolve(root)
    this.origin = origin
    this.profile = profile
    this.documents = documents
  }

  /**
   * Build an in-memory project blueprint without touching the target directory.
   * @param root - target project directory.
   * @param request - complete creation request.
   * @returns uncommitted project snapshot.
   */
  static create(root: string, request: ProjectCreationRequest): SdkProject {
    const app = request.features.find(selection => selection.id === 'app')
    const selectedInterface = app?.options[0]
    if (selectedInterface !== 'acp' && selectedInterface !== 'tui' && selectedInterface !== 'embed') {
      throw new Error('project creation requires one app feature option')
    }
    const profile: ProjectProfile = {
      name: request.name,
      description: request.description,
      runtime: request.runtime,
      runInterface: selectedInterface,
      packageManager: request.packageManager,
      releaseVersion: request.releaseVersion,
      ...request.linkWorkspaceRoot ? { linkWorkspaceRoot: resolve(request.linkWorkspaceRoot) } : {},
    }
    const templates = createProjectTemplateContext(profile)
    const manifest = createPackageJsonDoc(templates)
    const documents = new Map<string, ProjectFile>()
    documents.set(manifest.relativePath, manifest)
    documents.set('cordis.yml', CordisYamlFile.create())
    documents.set('.env.example', EnvFile.create('.env.example'))
    documents.set('tsconfig.json', TsConfigFile.create())
    for (const document of request.packageManager.configureWorkspace(manifest)) {
      documents.set(document.relativePath, document)
    }
    for (const document of createBaselineProjectArtifacts(templates)) {
      documents.set(document.relativePath, document)
    }
    return new SdkProject(root, 'create', profile, documents)
  }

  /**
   * Load an existing project from required and SDK-managed optional files.
   * @param root - existing project directory.
   * @returns disk-backed project snapshot.
   */
  static async open(root: string): Promise<SdkProject> {
    const absolute = resolve(root)
    const [manifestText, cordisText] = await Promise.all([
      readFile(resolve(absolute, 'package.json'), 'utf8'),
      readFile(resolve(absolute, 'cordis.yml'), 'utf8'),
    ])
    const manifest = PackageJsonFile.parse(manifestText)
    const cordis = CordisYamlFile.parse(cordisText)
    const value = manifest.value()
    const manager = await detectPackageManager(absolute, value)
    const entries = cordis.entries()
    const linkWorkspaceRoot = linkedRepositoryRoot(absolute, value)
    const profile: ProjectProfile = {
      name: value.name ?? basename(absolute),
      description: typeof value.description === 'string' ? value.description : '',
      runtime: { model: runtimeModel(entries) },
      runInterface: runInterface(entries),
      packageManager: manager,
      releaseVersion: releaseVersion(value),
      ...linkWorkspaceRoot ? { linkWorkspaceRoot } : {},
    }
    const documents = new Map<string, ProjectFile>([
      ['package.json', manifest],
      ['cordis.yml', cordis],
    ])
    await Promise.all(OPTIONAL_DOCUMENTS.map(async (path) => {
      try {
        const text = await readFile(resolve(absolute, path), 'utf8')
        documents.set(path, parseOptionalDocument(path, text))
      } catch (error) {
        /* v8 ignore next -- optional-file reads fail normally only with ENOENT; other IO faults surface */
        if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
      }
    }))
    return new SdkProject(absolute, 'disk', profile, documents)
  }

  /**
   * Read the root package manifest defensively.
   * @returns cloned manifest value.
   */
  packageManifest(): Readonly<PackageManifest> {
    return this.packageJson.value()
  }

  /**
   * Read Cordis config entries defensively in file order.
   * @returns cloned Cordis config entries.
   */
  cordisConfigEntries(): readonly CordisConfigEntry[] {
    return this.cordis.entries()
  }

  /**
   * Check whether this snapshot contains one managed document.
   * @param path - project-relative document path.
   * @returns whether the document is loaded.
   */
  hasDocument(path: string): boolean {
    return this.documents.has(path)
  }

  /**
   * Read one environment variable from a loaded dotenv document.
   * @param path - environment file to read.
   * @param name - variable name.
   * @returns variable value when present.
   */
  readEnvironment(path: '.env' | '.env.example', name: string): string | undefined {
    const document = this.documents.get(path)
    if (!document) return undefined
    if (!(document instanceof EnvFile)) throw new Error(`${path} is not an environment document`)
    return document.get(name)
  }

  /** Read the root package document. */
  get packageJson(): PackageJsonFile {
    const document = this.documents.get('package.json')
    if (!(document instanceof PackageJsonFile)) throw new Error('project package.json is missing or invalid')
    return document
  }

  /** Read the root Cordis document. */
  get cordis(): CordisYamlFile {
    const document = this.documents.get('cordis.yml')
    if (!(document instanceof CordisYamlFile)) throw new Error('project cordis.yml is missing or invalid')
    return document
  }

  /**
   * Return one managed document without exposing the aggregate map.
   * @param path - project-relative document path.
   * @returns loaded document when present.
   */
  document(path: string): ProjectFile | undefined {
    return this.documents.get(path)
  }

  /**
   * Create the only mutable boundary for this snapshot.
   * @param registry - feature catalog governing edits.
   * @returns isolated edit session.
   */
  edit(registry: FeatureRegistry): ProjectEditSession {
    return new ProjectEditSession(this, registry)
  }

  /**
   * Clone every managed document for an isolated edit session.
   * @returns project-relative document map.
   */
  cloneDocuments(): Map<string, ProjectFile> {
    return new Map([...this.documents].map(([path, document]) => [path, document.clone()]))
  }

  /**
   * Reload this aggregate from committed disk state.
   * @returns fresh disk-backed snapshot.
   */
  reopen(): Promise<SdkProject> {
    return SdkProject.open(this.root)
  }

}
