import {IStorageProvider} from "../storage/index.js"
import path from "node:path"
import {randomUUID} from "crypto"
import {BranchList, Commit, CommitList, Difference, Item, ItemChange, ItemList} from "./types.js"

export const PROJECT_DIR = ".mvcs"
export const CONTENT_DIR = "contents"
const CONTENT_DUMMY = "DUMMY"

export type ProjectDump = {
    id: string
    authorId: string
    title: string
    description?: string
    branches: { [k: string]: string }
    defaultBranch?: string
    currentBranch?: string
    commits: { [k: string]: Commit }
    rootCommitId?: string
    currentCommitId?: string
    items: { [k: string]: Item }
}

export type ProjectDumpKey = keyof ProjectDump

export const PROJECT_DUMP_KEYS: ProjectDumpKey[] = [
    "id",
    "authorId",
    "title",
    "description",
    "branches",
    "defaultBranch",
    "currentBranch",
    "commits",
    "rootCommitId",
    "currentCommitId",
    "items",
]

export class Project {
    sp: IStorageProvider
    id = "EMPTY_ID"
    authorId = "EMPTY_AUTHOR_ID"
    title = "EMPTY_TITLE"
    description?: string
    workingDir = "EMPTY_WORKING_DIR"
    branches: BranchList = new Map<string, string>()
    defaultBranch?: string
    currentBranch?: string
    commits: CommitList = new Map<string, Commit>()
    rootCommitId?: string
    currentCommitId?: string
    items: ItemList = new Map<string, Item>()

    private constructor(sp: IStorageProvider, workingDir: string, authorId?: string, title?: string, description?: string) {
        this.sp = sp
        this.workingDir = path.resolve(workingDir)

        if (authorId && title) {
            this.id = randomUUID()
            this.authorId = authorId
            this.title = title
            this.description = description
        }
    }

    static async fromFile(dirPath: string, sp: IStorageProvider): Promise<Project> {
        const project = new Project(sp, dirPath)
        await project.load()
        return project
    }

    static async create(
        sp: IStorageProvider,
        workingDir: string,
        authorId: string,
        title: string,
        description?: string
    ): Promise<Project> {
        const project = new Project(sp, workingDir, authorId, title, description)
        await project.save()
        return project
    }

    toJSON(): ProjectDump {
        return {
            id: this.id,
            authorId: this.authorId,
            title: this.title,
            description: this.description,
            branches: Object.fromEntries(this.branches),
            defaultBranch: this.defaultBranch,
            currentBranch: this.currentBranch,
            commits: Object.fromEntries(this.commits),
            rootCommitId: this.rootCommitId,
            currentCommitId: this.currentCommitId,
            items: Object.fromEntries(this.items)
        }
    }

    fromJSON(dump: ProjectDump) {
        const keysToImport = PROJECT_DUMP_KEYS.filter(k => k in dump)
        const mapNames = ["branches", "commits", "items"]
        const mapsToImport = keysToImport.filter(k => mapNames.includes(k))
        for (const key of keysToImport.filter(k => !mapsToImport.includes(k))) {
            (this as any)[key] = dump[key]
        }
        for (const mapName of mapsToImport) {
            (this as any)[mapName] = new Map(Object.entries(dump[mapName] as any))
        }
    }

    async load() {
        const filePath = this.getProjectFilePath();
        const projectFile = await this.sp.readFile(filePath)
        const projectDump: ProjectDump = JSON.parse((await projectFile.readData()).toString())
        this.fromJSON(projectDump)
    }

    async save() {
        const filePath = this.getProjectFilePath();
        if (!await this.sp.exists(filePath)) {
            await this.sp.createFile(filePath, Buffer.from("{}"))
        }
        const file = await this.sp.readFile(filePath)
        await file.writeData(Buffer.from(JSON.stringify(this.toJSON())))
    }

    getProjectFilePath(): string {
        return path.join(this.workingDir, PROJECT_DIR, "project.json");
    }

    getContentPath(content: string) {
        return path.join(this.workingDir, PROJECT_DIR, CONTENT_DIR, content)
    }

    async addContent(sourcePath: string): Promise<string> {
        const file = await this.sp.readFile(sourcePath)
        const hash = await file.getDataHash()

        for (const item of this.items.values()) {
            const candidatePath = this.getContentPath(item.content)
            const candidateHash = await (await this.sp.readFile(candidatePath)).getDataHash()
            if (candidateHash === hash) {
                return item.content
            }
        }

        const contentPath = randomUUID()
        await this.sp.copyFile(sourcePath, this.getContentPath(contentPath))
        return contentPath
    }

    matchCommitId(idPart: string): string {
        if (idPart.length < 7) {
            throw new Error("You must specify at least 7 symbols of ID")
        }
        const candidates = [...this.commits.keys()].filter(id => id.startsWith(idPart))
        if (candidates.length === 0) {
            throw new Error(`No ID candidate for ${idPart} found`)
        }
        if (candidates.length > 1) {
            throw new Error(`Multiple ID candidates were found for ${idPart}`)
        }
        return candidates.pop()!
    }

    getCurrentCommit(): Commit | undefined {
        let currentCommitId: string | undefined = this.currentCommitId

        if (!currentCommitId && this.commits.size > 0) {
            throw new Error("No current commit")
        }

        if (currentCommitId && !this.commits.has(currentCommitId)) {
            throw new Error("Current commit not found in the Commit List")
        }

        return currentCommitId ? this.commits.get(currentCommitId) : undefined
    }

    async getCommitItems(commitId: string): Promise<ItemList> {
        commitId = this.matchCommitId(commitId)
        const targetCommit = this.commits.get(commitId)
        if (!targetCommit) {
            throw new Error(`Commit ${commitId} not found in the Commit List`)
        }

        const commitChain = this.buildCommitChain(targetCommit);

        const commitItems: ItemList = new Map<string, Item>()
        for (const commit of commitChain) {
            this.applyCommitChanges(commit, commitItems);
        }

        for (const item of commitItems.values()) {
            const file = await this.sp.readFile(this.getContentPath(item.content))
            item.contentHash = await file.getDataHash()
        }

        return commitItems
    }

    buildCommitChain(targetCommit: Commit): Commit[] {
        const commitChain: Commit[] = [targetCommit]

        while (commitChain[0] && commitChain[0].id !== this.rootCommitId) {
            const commit = commitChain[0]
            if (!commit.parent) {
                break
            }

            const parentCommit = this.commits.get(commit.parent)
            if (!parentCommit) {
                throw new Error(`Parent commit ${commit.parent} not found in the Commit List`)
            }

            commitChain.unshift(parentCommit)
        }

        return commitChain;
    }

    applyCommitChanges(commit: Commit, resultItems: ItemList): void {
        for (const change of commit.changes) {
            if (change.to) {
                const itemId = change.to
                if (!this.items.has(itemId)) {
                    throw new Error(`Item ${itemId} not found in the Items List`)
                }
                resultItems.set(itemId, this.items.get(itemId)!)
            }
            if (change.from) {
                resultItems.delete(change.from)
            }
        }
    }

    async getCurrentFiles(): Promise<string[]> {
        const currentFilesAndDirs = await this.sp.readDirDeep(this.workingDir, [`${PROJECT_DIR}/**`])
        const currentFiles: string[] = []

        await Promise.all(currentFilesAndDirs.map(async p => {
            if (await this.sp.isFile(p)) {
                currentFiles.push(p)
            }
        }))

        return currentFiles;
    }

    async filesToItems(files: string[]): Promise<ItemList> {
        const createItem = (content: string, path: string, contentHash?: string): Item => ({
            id: randomUUID(),
            content,
            path,
            contentHash
        })

        const items: ItemList = new Map<string, Item>()

        for (const filePath of files) {
            if (filePath.includes(PROJECT_DIR)) continue

            const file = await this.sp.readFile(filePath)

            const item = createItem(
                CONTENT_DUMMY,
                filePath,
                await file.getDataHash()
            )

            items.set(item.id, item)
        }

        return items
    }

    async status(files: string[] | undefined = undefined): Promise<Difference> {
        const lastItems = this.currentCommitId ? await this.getCommitItems(this.currentCommitId) : new Map<string, Item>()

        if (!files) {
            files = await this.getCurrentFiles()
        } else {
            for (const item of lastItems.values()) {
                if (!files.includes(item.path)) {
                    lastItems.delete(item.id)
                }
            }
        }

        const existingFiles = []
        for (const file of files) {
            if (await this.sp.isFile(file)) {
                existingFiles.push(file)
            }
        }

        const fileItems = await this.filesToItems(existingFiles)

        return await this.diff(lastItems, fileItems)
    }

    async diff(beforeItems: ItemList, afterItems: ItemList): Promise<Difference> {
        const before = new Map(Array.from(beforeItems.entries()).map(([_, i]) => [i.path, i]))
        const after = new Map(Array.from(afterItems.entries()).map(([_, i]) => [i.path, i]))
        const paths = new Set([...before.keys(), ...after.keys()])

        const result: Difference = {
            added: new Map<string, Item>(),
            removed: new Map<string, Item>(),
            changed: new Map<string, Item>(),
            unchanged: new Map<string, Item>()
        }

        for (const filePath of paths) {
            const inBefore = before.has(filePath), inAfter = after.has(filePath)
            if (!inBefore && inAfter) {
                result.added.set(filePath, after.get(filePath)!)
            } else if (inBefore && !inAfter) {
                result.removed.set(filePath, before.get(filePath)!)
            } else if (inBefore && inAfter) {
                const beforeItem = before.get(filePath)!
                const afterItem = after.get(filePath)!

                if (beforeItem.contentHash !== afterItem.contentHash) {
                    result.changed.set(filePath, afterItem)
                } else {
                    result.unchanged.set(filePath, beforeItem)
                }
            }
        }

        return result
    }

    async commit(files: string[], authorId: string, title: string, description: string = ""): Promise<Commit> {
        this.ensureValidBranchForCommit()

        const {added, removed, changed} = await this.status(files)
        const changes: ItemChange[] = []

        for (const item of removed.values()) {
            changes.push({from: item.id})
        }

        for (const item of added.values()) {
            if (item.content === CONTENT_DUMMY) {
                item.content = await this.addContent(item.path)
            }
            this.items.set(item.id, item)
            changes.push({to: item.id})
        }

        const lastCommit = this.currentCommitId ? await this.getCommitItems(this.currentCommitId) : null
        const lastItems = lastCommit ? Array.from(lastCommit.values()) : []
        for (const item of changed.values()) {
            if (item.content === CONTENT_DUMMY) {
                item.content = await this.addContent(item.path)
            }
            this.items.set(item.id, item)

            const prevItem = lastItems.find(i => i.path === item.path)!
            changes.push({from: prevItem.id, to: item.id})
        }

        const newCommit: Commit = {
            id: randomUUID(),
            parent: this.getCurrentCommit()?.id,
            children: [],
            authorId,
            title,
            description,
            date: (new Date()).toISOString(),
            changes
        }

        if (this.commits.size === 0) {
            this.rootCommitId = newCommit.id
            this.currentBranch = this.currentBranch ?? "main"
            this.defaultBranch = this.currentBranch
        }
        this.commits.set(newCommit.id, newCommit)
        this.branches.set(this.currentBranch!, newCommit.id)
        this.currentCommitId = newCommit.id

        return newCommit
    }

    ensureValidBranchForCommit(): void {
        if (this.commits.size === 0) return;

        const err = new Error("Cannot commit when not at the branch")

        if (!this.currentBranch) {
            throw err
        }
        if (!this.branches.has(this.currentBranch)) {
            throw err
        }
        if (this.branches.get(this.currentBranch) !== this.currentCommitId) {
            throw err
        }
    }

    async checkout(commitId: string) {
        commitId = this.matchCommitId(commitId)
        const commitItems = await this.getCommitItems(commitId)

        const currentFiles: string[] = await this.getCurrentFiles()
        const commitFiles = [...commitItems.values()].map(i => i.path)

        for (const filePath of currentFiles) {
            if (!commitFiles.includes(filePath)) {
                await this.sp.deleteFileOrDir(filePath)
            }
        }

        for (const item of commitItems.values()) {
            const itemContentPath = this.getContentPath(item.content)
            const itemContent = await this.sp.readFile(itemContentPath)
            const itemHash = await itemContent.getDataHash()

            if (currentFiles.includes(item.path)) {
                const currentContent = await this.sp.readFile(item.path)
                const currentHash = await currentContent.getDataHash()
                if (itemHash === currentHash) continue
            }

            await this.sp.copyFile(itemContentPath, item.path)
        }

        this.currentCommitId = commitId
    }

    async checkoutBranch(branchName: string) {
        this.throwIfBranchNotFound(branchName)
        const branchCommit = this.branches.get(branchName)!
        if (!this.commits.has(branchCommit)) {
            throw new Error(`Commit ${branchCommit} (branch ${branchName}) not found`)
        }
        await this.checkout(branchCommit)
        this.currentBranch = branchName
    }

    createBranch(branchName: string) {
        if (!this.currentCommitId && this.commits.size > 0) {
            throw new Error("No current commit")
        }
        this.throwIfBranchFound(branchName)
        this.branches.set(branchName, this.currentCommitId!)
        if (!this.defaultBranch) {
            this.defaultBranch = branchName
        }
    }

    deleteBranch(branchName: string) {
        this.throwIfBranchNotFound(branchName)
        if (this.branches.size === 1) {
            throw new Error(`Cannot delete the only branch in the project`)
        }
        if (this.currentBranch === branchName) {
            throw new Error(`Cannot delete the branch you"re currently on`)
        }
        if (this.defaultBranch === branchName) {
            throw new Error(`Cannot delete default branch`)
        }
        this.branches.delete(branchName)
    }

    renameBranch(oldName: string, newName: string) {
        this.throwIfBranchNotFound(oldName)
        this.throwIfBranchFound(newName)
        this.branches.set(newName, this.branches.get(oldName)!)
        if (this.currentBranch === oldName) {
            this.currentBranch = newName
        }
        if (this.defaultBranch === oldName) {
            this.defaultBranch = newName
        }
        this.branches.delete(oldName)
    }

    setDefaultBranch(branchName: string) {
        this.throwIfBranchNotFound(branchName)
        this.defaultBranch = branchName
    }

    throwIfBranchNotFound(branchName: string) {
        if (!this.branches.has(branchName)) {
            throw new Error(`Branch ${branchName} not found`)
        }
    }

    throwIfBranchFound(branchName: string) {
        if (this.branches.has(branchName)) {
            throw new Error(`Branch ${branchName} already exists`)
        }
    }
}
