import Foundation

package extension Configuration {
    struct FileGraph: Hashable {
        // MARK: - Properties
        private static let defaultRemoteConfigTimeout: TimeInterval = 2
        private static let defaultRemoteConfigTimeoutIfCached: TimeInterval = 1

        internal let rootDirectory: URL

        private let ignoreParentAndChildConfigs: Bool

        private var vertices: Set<Vertex>
        private var edges: Set<Edge>

        private var isBuilt = false

        // MARK: - Initializers
        internal init(commandLineChildConfigs: [URL], rootDirectory: URL, ignoreParentAndChildConfigs: Bool) {
            let verticesArray = commandLineChildConfigs.map { config in
                Vertex(string: config.filepath, rootDirectory: rootDirectory.filepath, isInitialVertex: true)
            }
            vertices = Set(verticesArray)
            edges = Set(zip(verticesArray, verticesArray.dropFirst()).map { Edge(parent: $0.0, child: $0.1) })

            self.rootDirectory = rootDirectory
            self.ignoreParentAndChildConfigs = ignoreParentAndChildConfigs
        }

        /// Dummy init to get a FileGraph that just represents a root directory
        internal init(rootDirectory: URL) {
            self.init(
                commandLineChildConfigs: [],
                rootDirectory: rootDirectory,
                ignoreParentAndChildConfigs: false
            )

            isBuilt = true
        }

        // MARK: - Methods
        internal mutating func resultingConfiguration(
            enableAllRules: Bool,
            onlyRule: [String],
            cachePath: String?
        ) throws -> Configuration {
            // Build if needed
            if !isBuilt {
                try build()
            }

            return try merged(
                configurationData: try validate(),
                enableAllRules: enableAllRules,
                onlyRule: onlyRule,
                cachePath: cachePath
            )
        }

        internal func includesFile(atPath path: URL) -> Bool {
            guard isBuilt else { return false }

            return vertices.contains { vertex in
                if case let .existing(filePath) = vertex.filePath {
                    return path.filepath == filePath
                }

                return false
            }
        }

        // MARK: Building
        private mutating func build() throws {
            for vertex in vertices {
                try process(vertex: vertex)
            }

            isBuilt = true
        }

        private mutating func process(
            vertex: Vertex,
            remoteConfigTimeoutOverride: TimeInterval? = nil,
            remoteConfigTimeoutIfCachedOverride: TimeInterval? = nil
        ) throws {
            try vertex.build(
                remoteConfigTimeout: remoteConfigTimeoutOverride ?? Configuration.FileGraph.defaultRemoteConfigTimeout,
                remoteConfigTimeoutIfCached: remoteConfigTimeoutIfCachedOverride
                    ?? remoteConfigTimeoutOverride ?? Configuration.FileGraph.defaultRemoteConfigTimeoutIfCached
            )

            if !ignoreParentAndChildConfigs {
                try processPossibleReferenceIgnoringFileAbsence(
                    ofType: .childConfig,
                    from: vertex,
                    remoteConfigTimeoutOverride: remoteConfigTimeoutOverride,
                    remoteConfigTimeoutIfCachedOverride: remoteConfigTimeoutIfCachedOverride)

                try processPossibleReferenceIgnoringFileAbsence(
                    ofType: .parentConfig,
                    from: vertex,
                    remoteConfigTimeoutOverride: remoteConfigTimeoutOverride,
                    remoteConfigTimeoutIfCachedOverride: remoteConfigTimeoutIfCachedOverride)
            }
        }

        private mutating func processPossibleReferenceIgnoringFileAbsence(
            ofType type: EdgeType,
            from vertex: Vertex,
            remoteConfigTimeoutOverride: TimeInterval?,
            remoteConfigTimeoutIfCachedOverride: TimeInterval?
        ) throws {
            do {
                try processPossibleReference(
                    ofType: type,
                    from: vertex,
                    remoteConfigTimeoutOverride: remoteConfigTimeoutOverride,
                    remoteConfigTimeoutIfCachedOverride: remoteConfigTimeoutIfCachedOverride
                )
            } catch {
                // If a child or parent config file doesn't exist, do not fail the rest of the config tree.
                // Instead, just ignore this leaf of the config. Otherwise, rethrow the error.
                guard case let Issue.fileNotFound(path) = error else {
                    throw error
                }
                queuedPrintError("""
                    A local configuration at \(path) was not found. \
                    Ignoring this part of the configuration.
                    """
                )
            }
        }

        private mutating func processPossibleReference(
            ofType type: EdgeType,
            from vertex: Vertex,
            remoteConfigTimeoutOverride: TimeInterval?,
            remoteConfigTimeoutIfCachedOverride: TimeInterval?
        ) throws {
            let key = type == .childConfig
                ? Configuration.Key.childConfig.rawValue
                : Configuration.Key.parentConfig.rawValue

            if let reference = vertex.configurationDict[key] as? String {
                let referencedVertex = Vertex(
                    string: reference,
                    rootDirectory: vertex.rootDirectory,
                    isInitialVertex: false
                )

                // Local vertices are allowed to have local / remote references
                // Remote vertices are only allowed to have remote references
                if vertex.originatesFromRemote, !referencedVertex.originatesFromRemote {
                    throw Issue.genericWarning("Remote configs are not allowed to reference local configs.")
                }
                let existingVertex = findPossiblyExistingVertex(sameAs: referencedVertex)
                let existingVertexCopy = existingVertex.map { $0.copy(withNewRootDirectory: rootDirectory.filepath) }

                edges.insert(
                    type == .childConfig
                        ? Edge(parent: vertex, child: existingVertexCopy ?? referencedVertex)
                        : Edge(parent: existingVertexCopy ?? referencedVertex, child: vertex)
                )

                if existingVertex == nil {
                    vertices.insert(referencedVertex)

                    // Use timeout config from vertex / parent of vertex if some
                    let remoteConfigTimeout =
                        vertex.configurationDict[Configuration.Key.remoteConfigTimeout.rawValue]
                            as? TimeInterval
                            ?? remoteConfigTimeoutOverride // from vertex parent
                    let remoteConfigTimeoutIfCached =
                        vertex.configurationDict[Configuration.Key.remoteConfigTimeoutIfCached.rawValue]
                            as? TimeInterval
                            ?? remoteConfigTimeoutIfCachedOverride // from vertex parent

                    try process(
                        vertex: referencedVertex,
                        remoteConfigTimeoutOverride: remoteConfigTimeout,
                        remoteConfigTimeoutIfCachedOverride: remoteConfigTimeoutIfCached
                    )
                }
            }
        }

        private func findPossiblyExistingVertex(sameAs vertex: Vertex) -> Vertex? {
            vertices.first {
                $0.originalRemoteString != nil && $0.originalRemoteString == vertex.originalRemoteString
            } ?? vertices.first { $0.filePath == vertex.filePath }
        }

        // MARK: Validating
        /// Validates the Graph and throws failures
        /// If successful, returns array of configuration dicts that represents the graph
        private func validate() throws -> [(configurationDict: [String: Any], rootDirectory: URL)] {
            // Detect cycles via back-edge detection during DFS
            func walkDown(stack: [Vertex]) throws {
                // Please note that the equality check (`==`), not the identity check (`===`) is used
                let children = edges.filter { $0.parent == stack.last }.map { $0.child! }
                if stack.contains(where: children.contains) {
                    throw Issue.genericWarning("There's a cycle of child / parent config references. "
                        + "Please check the hierarchy of configuration files passed via the command line "
                        + "and the childConfig / parentConfig entries within them.")
                }
                try children.forEach { try walkDown(stack: stack + [$0]) }
            }

            try vertices.forEach { try walkDown(stack: [$0]) }

            // Detect ambiguities
            if (edges.contains { edge in edges.filter { $0.parent == edge.parent }.count > 1 }) {
                throw Issue.genericWarning("There's an ambiguity in the child / parent configuration tree: "
                    + "More than one parent is declared for a specific configuration, "
                    + "where there should only be exactly one.")
            }

            if (edges.contains { edge in edges.filter { $0.child == edge.child }.count > 1 }) {
                throw Issue.genericWarning("There's an ambiguity in the child / parent configuration tree: "
                    + "More than one child is declared for a specific configuration, "
                    + "where there should only be exactly one.")
            }

            // The graph should be like an array if validation passed -> return that array
            guard
                let startingVertex = (vertices.first { vertex in !edges.contains { $0.child == vertex } })
            else {
                guard vertices.isEmpty else {
                    throw Issue.genericWarning("Unknown Configuration Error")
                }

                return []
            }

            var verticesToMerge = [startingVertex]
            while let vertex = (edges.first { $0.parent == verticesToMerge.last }?.child) {
                guard !verticesToMerge.contains(vertex) else {
                    // This shouldn't happen on a cycle free graph but let's safeguard
                    throw Issue.genericWarning("Unknown Configuration Error")
                }

                verticesToMerge.append(vertex)
            }

            return verticesToMerge.map {
                (
                    configurationDict: $0.configurationDict,
                    rootDirectory: $0.rootDirectory.url(directoryHint: .isDirectory)
                )
            }
        }

        // MARK: Merging
        private func merged(
            configurationData: [(configurationDict: [String: Any], rootDirectory: URL)],
            enableAllRules: Bool,
            onlyRule: [String],
            cachePath: String?
        ) throws -> Configuration {
            // Split into first & remainder; use empty dict for first if the array is empty
            let firstConfigurationData = configurationData.first ?? (configurationDict: [:], rootDirectory: URL.cwd)
            let configurationData = Array(configurationData.dropFirst())

            // Build first configuration
            var firstConfiguration = try Configuration(
                dict: firstConfigurationData.configurationDict,
                location: firstConfigurationData.rootDirectory,
                enableAllRules: enableAllRules,
                onlyRule: onlyRule,
                cachePath: cachePath
            )

            // Set the config's rootDirectory to rootDirectory (+ adjust included / excluded paths that relate to it).
            // firstConfigurationData.rootDirectory may be different from rootDirectory,
            // e. g. when ../file.yml is passed as the first config
            firstConfiguration.fileGraph = Self(rootDirectory: rootDirectory)
            firstConfiguration.makeIncludedAndExcludedPaths(relativeTo: rootDirectory)

            // Build succeeding configurations
            return try configurationData.reduce(firstConfiguration) {
                var childConfiguration = try Configuration(
                    parentConfiguration: $0,
                    dict: $1.configurationDict,
                    location: $1.rootDirectory,
                    enableAllRules: enableAllRules,
                    onlyRule: onlyRule,
                    cachePath: cachePath
                )
                childConfiguration.fileGraph = Self(rootDirectory: $1.rootDirectory)

                return $0.merged(withChild: childConfiguration, rootDirectory: rootDirectory)
            }
        }
    }
}
